Community API
Endpoints for the community system (Phase 5) — Skool-style, mentor-owned spaces combining a discussion feed, XP + levelling, paid subscriptions, and a gated course library. Covers a mentor creating and managing a community; any user discovering, joining (free or paid — one endpoint), and participating in the feed; and the course-gating that links communities to the course module.
Discovery tags are admin-managed. The tag vocabulary behind the chip row, the tag filter and the tag picker is seeded with a default set and then edited at runtime by an admin — read it from §18b, never hardcode it. Admin side:
admin-community-tag-api.md.
⚠️ Breaking — the level ladder was renumbered. The floor is now level 0, not 1, and owner-configured tiers start at 1, not 2. A migration shifted every stored level down by one:
community_level_thresholds.level,community_members.level,course_modules.unlock_at_levelandcommunity_unlocks.required_level. Relative ordering and every gate are preserved — a member on the old level 3 is on level 2 and sees exactly what they saw before — but every displayed level number drops by one, so hardcoded level checks and copy like "reach Level 2" need revisiting. See §6, §17c and the XP & levelling model section.
- Base URL:
/api/v1 - Auth: mentor and mentee endpoints require a Sanctum bearer token —
Authorization: Bearer {token}. Public discovery needs no auth. The checkout pages are reached through a signed URL (no login). - Content type:
application/json(media 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)
Surfaces. Owner management lives under the mentor surface. Despite the prefix, most of it is not owner-only — see Who may manage a community below. Participation (join, feed, courses) lives under the mentee surface because every user has a mentee profile; membership is keyed by
user_id, so a mentor participates in another mentor's community through the same mentee endpoints. The gated course catalog is the one exception — it is mirrored on the mentor surface as a convenience (§7a).
Scope note. Real payment settlement is deferred (no gateway yet): paid subscriptions settle through a fake checkout page mirroring the booking flow — a signed link with Pay / Cancel.
payment_idstays null until the payments module lands. Events calendar and its "attend event" XP are not in this cut. In-app notifications write synchronously to the database channel (no queue worker required).
Video note. A community post carries either attachments (
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 and uploads it as thethumbnailfield; the server stores it and echoes it back asvideo.thumbnail(nullwhen none was sent).
Response envelope
Success { "status": "success", "message": "…", "data": { } }
Error { "status": "error", "message": "…", "data": { } }
Validation errors (422) are the exception to the envelope — they return Laravel's
native shape, with the field errors under errors, not data:
{ "message": "That topic is not one we recognise.",
"errors": { "tags.1": ["That topic is not one we recognise."] } }
Status codes
| Code | Meaning |
|---|---|
| 200 | OK (read, update, lifecycle action) |
| 201 | Resource created (community, post, comment, share, report, join — free or checkout) |
| 401 | Missing/invalid token |
| 403 | Authenticated but not an active member (feed, post, comment, like, share, report, seen, me, courses, roles), a member whose role is too low (pin/unpin, post removal), or a share that would move a post to an audience it was not published to |
| 404 | Not found, or not owned by the caller (mentor scoping) |
| 409 | Content already reported by this user |
| 422 | Validation error / invalid state (e.g. join a community you already belong to, non-increasing levels, reporting your own content) |
| 500 | Server error |
Enums
Community status: draft · published · archived
Pricing type: free · one_time · subscription
Billing interval: monthly · yearly (required only for subscription; null otherwise)
Visibility: public · private
A private community is off discovery entirely — absent from GET /public/communities
and from global search — but still resolves by slug, so invite links keep working (§18).
Member role: owner · admin · moderator · member (descending authority)
Served as a vocabulary with its permission matrix by §17a — read it rather than
hardcoding the four. owner is never assignable; ownership is not transferable.
Member status: active · pending · left · removed · rejected · banned
(only active may participate)
pending is a join request awaiting approval (§9b); left is a self-exit (§9a) and may
rejoin; removed is owner moderation; rejected is a refused request, which may be made
again; banned is final — a banned user is refused by join.
Subscription status: pending · active · cancelled · expired
Post type: text · poll · share · video
Share destination (destination on the share route): community (default — this
community, or another via target_community_id) · feed (the sharer's own public profile feed)
Shared-post source (shared_post_source, read-only): community · feed
Report reason: spam · harassment · hate_speech · violence · nudity ·
misinformation · other
Report status: pending · reviewed · dismissed · actioned (capture-only — stays pending)
XP catalogue — the activities a community can award points for. These are the seeded defaults; every one is owner-configurable per community (§17i).
| key | name | category | points | daily cap | dedupe |
|---|---|---|---|---|---|
post_to_feed |
Post to feed | feed | 10 | 3 | — |
comment_on_post |
Comment on a post | feed | 5 | 5 | — |
receive_like |
Receive a like | feed | 2 | — | awarded to the author, posts and comments alike |
share_post |
Share a post | feed | 3 | 2 | — |
enroll_course |
Enroll in a course | courses | 15 | — | once per course |
complete_lesson |
Complete a lesson | courses | 20 | — | once per lesson |
course_watch_time |
Course watch time | courses | 5 | 6 | per 10 minutes newly watched |
pass_quiz |
Pass a quiz | courses | 25 | — | once per quiz |
finish_course |
Finish a course | courses | 100 | — | once per course |
daily_visit |
Daily visit | community | 2 | 1 | — |
invite_accepted |
Invite a member who joins | community | 50 | — | once per invite, awarded to the inviter |
book_session |
Book a session with the host | community | 30 | — | once per community |
chat_with_members |
Chat with members | community | 2 | 3 | off by default |
Categories: feed · courses · community
Shared objects
Community object
{
"id": "9b6f…",
"mentor_id": "8a1c…",
"name": "Product Leaders Circle",
"slug": "product-leaders-circle",
"tagline": "Level up your PM career",
"description": "…",
"status": "published",
"status_label": "Published",
"visibility": "public",
"visibility_label": "Public",
"language": "English",
"tags": [ { "slug": "tech", "name": "Tech", "icon": "cpu" } ],
"pricing_type": "subscription",
"pricing_type_label": "Subscription",
"billing_interval": "monthly",
"billing_interval_label": "Monthly",
"price": "19.00",
"currency": "USD",
"member_count": 42,
"online_count": 3,
"admin_count": 2,
"cover_url": null,
"icon_url": null,
"media": [ { "id": 17, "url": "https://…/gallery/shot.jpg", "alt": "Members at the 2026 meetup" } ],
"highlights": ["Weekly live sessions with the owner"],
"guidelines": ["Be respectful — no harassment."],
"owner": { "id": "7d2e…", "username": "janedoe", "name": "Jane Doe", "avatar_url": null },
"mentor": { "id": "8a1c…", "headline": "…", "average_rating": "4.90", "name": "Jane Doe", "avatar_url": null },
"is_member": true,
"my_membership": {
"id": "1a2b…", "member_role": "member", "member_role_label": "Member",
"status": "active", "status_label": "Active", "level": 1, "xp": 140,
"joined_at": "2026-07-06T10:00:00+00:00"
},
"level_thresholds": [ { "id": "…", "level": 1, "xp_required": 100, "label": "Contributor" } ],
"pricing_changed_at": null,
"published_at": "2026-07-06T09:00:00+00:00",
"created_at": "…",
"updated_at": "…"
}
mentor/tags/level_thresholdsare included only when eager-loaded (show responses).
level_thresholdsis member-only. It is present for an active member and for the owner, and absent for a guest or non-member —GET /communities/{id}/levelsrefuses them, so the public payload does not hand the same ladder out sideways. Guard the key.
languageis drawn from a fixed vocabulary andtags[]from the admin-managed tag vocabulary; both are published by §18a, the vocabulary alone by §18b, and both are validated on write (§2).
tags[].iconis an icon key the admin set on the tag —nullwhen they left it unset. Because admins add tags at runtime, a client-side slug-to-icon map cannot cover a slug it has never seen; read this key first and fall back to your own map.
About-tab content. highlights[] and guidelines[] are ordered lists of short strings
rendered verbatim; media[] is the gallery, each entry carrying its alt text and an id
the owner's remove button addresses (§2b). All three default to empty, never null.
Viewer-scoped keys. is_member, my_membership and subscription describe the
caller, and only carry meaning when the query loaded the caller's own rows — which the
show, browse and mentor listings all do.
is_member—trueon an active membership, the unified access flag for free and paid alike.falsefor an authenticated non-member; omitted entirely for anonymous.my_membership— the caller's own row (role, status, level, xp) ornull. Reported at whatever status it holds,pendingincluded — an applicant has to be told their request landed — whileis_memberstays derived from an active row alone, so nothing about access moves with it.online_count/admin_count— computed in the listing query, so a page of communities never degrades into a count per row.onlinemeans seen inside the presence window (15 min);admincounts owner + admins.
billing_interval is null unless pricing_type is subscription. Free and one_time
communities carry null on both billing_interval and billing_interval_label, whatever was
posted when they were created, so the field can be read as the signal it looks like.
Member object
{
"id": "1a2b…", "community_id": "9b6f…", "user_id": "7d2e…",
"member_role": "member", "member_role_label": "Member",
"status": "active", "status_label": "Active",
"level": 0, "xp": 30,
"is_online": true,
"is_muted": false,
"muted_until": null,
"can_chat": true,
"can_view_profile": true,
"user": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null },
"joined_at": "2026-07-06T10:00:00+00:00"
}
is_online— seen inside the presence window (15 minutes).is_muted/muted_until—muted_untilisnullunless muted. An indefinite mute reads as a far-future date rather than a separate flag, so one comparison covers both.can_chat/can_view_profile— resolved for the viewer from the member's privacy settings (§17g). Absent on listings with no viewer context; treat absent astrue.level— 0 is the floor every member stands on; a brand-new row islevel: 0and 1 is the first rung they earn. See XP & levelling model.
status_label splits the two meanings of pending. The status value does not, so read
the label (or branch on approved_at, which my_membership carries):
status |
approved_at |
status_label |
What it means |
|---|---|---|---|
pending |
null |
Pending approval |
Waiting on the owner's decision — it is in the queue (§9b) |
pending |
set | Awaiting payment |
Cleared to join; nobody is reviewing it any more. They owe a payment — send them back through §9 join for a fresh checkout_url |
The owner's join-request queue (§9b) only ever holds undecided rows, so a row there always
reads Pending approval.
Post object
Mirrors the public-feed post shape (minus visibility/connection). type is text, poll,
share, or video; a share post carries the original under shared_post, a poll post
carries the poll under poll.
{
"id": "3c4d…",
"type": "text", "type_label": "Text",
"body": "Excited to be here!",
"reaction_count": 4, "comment_count": 2, "share_count": 1,
"my_reaction": "love",
"is_seen": false,
"reactions": [ { "type": "love", "count": 3 }, { "type": "like", "count": 1 } ],
"hashtags": ["laravel", "বাংলা"],
"image_urls": [],
"video": { "url": "https://…/clip.mp4", "thumbnail": "https://…/clip-thumb.jpg", "mime": "video/mp4" },
"author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": false },
"shared_post": null,
"shared_post_source": null,
"comments": [ { "…comment object…" } ],
"community_id": "9b6f…", "is_pinned": false, "pinned_at": null,
"created_at": "…", "updated_at": "…"
}
my_reaction is the caller's own reaction type (or null). reactions is the per-type
breakdown (count-desc), present on every response alongside the reaction_count total.
poll is present only on type: poll posts; video only on posts that carry a video. On
author, role is the author's platform role ("mentor", "mentee", "admin", or null
when they have none).
On type: share posts, shared_post is the original and shared_post_source says which
surface it came from — "community" for a nested community post object, "feed" for a
nested public-feed post object (see feed-api.md). Both keys are omitted on
non-share posts. See §13a for how a post crosses surfaces.
is_seen is the caller's own read state, present only on the community feed listing and
omitted on single-post reads — see §12a.
hashtags is the list of tag names auto-parsed from body on every create / update / share —
there is no field to send. ALWAYS an array, never null. Community posts draw on the same tag
vocabulary as the public feed, so #বাংলা written here is the same tag as #বাংলা written on the
feed; only the per-community tallies (§12b) are scoped. Parsing rules — including Bangla and other
non-Latin scripts — are identical to the feed's, see feed-api.md → Hashtags.
Poll object (post.poll)
Same shape as the public feed (see feed-api.md):
{
"id": "9a0b…",
"question": "Best day for the weekly call?",
"allows_multiple": false,
"expires_at": null,
"is_closed": false,
"total_votes": 12,
"options": [
{ "id": "…", "label": "Monday", "position": 0, "vote_count": 7, "voted_by_me": true },
{ "id": "…", "label": "Friday", "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": "7f8a…",
"post_id": "3c4d…",
"user_id": "7d2e…",
"parent_id": null,
"body": "Welcome aboard!",
"reaction_count": 2,
"my_reaction": "like",
"my_reaction_exists": true,
"reactions": [ { "type": "like", "count": 2 } ],
"author": { "id": "7d2e…", "username": "johndoe", "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).
Comments support one-level threading via parent_id. replies holds at most the first 3
replies (oldest first) as a preview so a thread renders in one round-trip; replies_count is the
full total. When replies_count exceeds the nested array, page the remainder via the replies
endpoint (§14). Both keys are omitted on reply objects and wherever replies are not loaded.
Subscription object
{
"id": "5e6f…", "community_id": "9b6f…", "user_id": "7d2e…",
"pricing_type": "subscription", "pricing_type_label": "Subscription",
"billing_interval": "monthly", "billing_interval_label": "Monthly",
"price_snapshot": "19.00", "currency": "USD",
"status": "active", "status_label": "Active",
"starts_at": "2026-07-06T10:00:00+00:00",
"ends_at": "2026-08-06T10:00:00+00:00",
"cancelled_at": null,
"created_at": "…"
}
Free members hold one too — pricing_type: "free", billing_interval: null,
price_snapshot: null, status: "active", ends_at: null. Read pricing_type (not the
object's presence) to tell a paying subscriber from a free member.
Mentor endpoints — owner management
Who may manage a community
Despite the /mentor prefix, most of this surface is owner or admin. An admin is an
ordinary member holding a managing role and need not have a mentor profile at all —
these routes resolve the caller either through the owning mentor profile or through a
membership role that manages the community, which is the single predicate behind the
manage_settings / manage_members / manage_courses columns in §17a.
| Endpoint | Owner | Admin | Why |
|---|---|---|---|
| §2 show / update, §2b gallery | ✔ | ✔ | manage_settings |
| §17i unlocks (XP economy) | ✔ | ✔ | manage_settings |
| §2a privacy | ✔ | ✔ | manage_settings |
| §3 publish / unpublish | ✔ | ✔ | manage_settings |
| §5 members (role, mute, ban, remove) | ✔ | ✔ | manage_members |
| §6 level thresholds | ✔ | ✔ | manage_settings |
| §7 post moderation, pin / unpin | ✔ | ✔ | moderate_content, restricted to managers |
| §7a owner course catalogue (drafts included) | ✔ | ✔ | manage_courses |
| §1 list / create | ✔ | A mentor's own communities; needs a mentor profile | |
| §3 archive | ✔ | Takes the community away from every member | |
| §2 delete | ✔ | Ownership, not configuration | |
| §4 pricing, §4a coupons | ✔ | manage_payments — the owner's alone |
Moderators are excluded from all of it. They moderate content (§7 is gated on managers, not moderators) and otherwise hold no management rights; they get 403.
403 vs 404. A write tells you it refused — 403 with a reason. The read in §2 answers 404 instead, so it never discloses that a community it will not show you exists. The owner-only rows above also 404 rather than 403, for the same reason.
All require an approved mentor profile. Another mentor's community (or a missing profile) → 404.
1. List / create communities
GET /api/v1/mentor/communities → { "data": { "communities": [ … ] } }
POST /api/v1/mentor/communities (multipart if sending cover/icon) → 201
Creating a community seeds the owner as the first member (role owner, member_count = 1)
and auto-generates a unique slug from the name. New communities are created published
(with published_at set) + public + free — live and discoverable from the moment they exist.
An owner who wants to work on one before anyone sees it takes it down with unpublish (§3).
GET returns every community the caller owns, in every status — drafts and archived ones
included, since publishing one is only possible from inside it. Every row carries status and
status_label so the client can badge anything that is not published and still link through.
Self-only. This lists the caller's own communities. To read another user's communities, use
GET /api/v1/user/{username}/communities(Authentication API, endpoint 43). Because the owner holds anownermembership row, that single paginated list covers created and joined communities, told apart byis_creator/member_role. Drafts are visible only to the owner.
Create payload
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Max 255 |
tagline |
string | no | Max 255 |
description |
string | no | |
pricing_type |
enum | no | free (default) · one_time · subscription |
billing_interval |
enum | conditionally | Required if pricing_type = subscription; stored as null for any other pricing type, whatever is sent |
price |
decimal | conditionally | Required for one_time / subscription |
currency |
— | ignored | Derived from the mentor's profile currency. Not accepted; a value sent here is stripped by validation. |
cover |
file | no | Image ≤ 5 MB |
icon |
file | no | Image ≤ 2 MB |
Request (JSON; use multipart/form-data with the same field names when sending cover/icon)
{
"name": "Product Leaders Circle",
"tagline": "Level up your PM career",
"description": "A space for senior PMs to trade playbooks.",
"pricing_type": "subscription",
"billing_interval": "monthly",
"price": "19.00",
"currency": "USD"
}
Response 201
{
"status": "success",
"message": "Community created successfully.",
"data": {
"community": {
"id": "9b6f…", "mentor_id": "8a1c…",
"name": "Product Leaders Circle", "slug": "product-leaders-circle",
"tagline": "Level up your PM career", "description": "A space for senior PMs to trade playbooks.",
"status": "published", "status_label": "Published",
"visibility": "public", "visibility_label": "Public",
"pricing_type": "subscription", "pricing_type_label": "Subscription",
"billing_interval": "monthly", "billing_interval_label": "Monthly",
"price": "19.00", "currency": "USD",
"member_count": 1,
"cover_url": null, "icon_url": null,
"pricing_changed_at": null, "published_at": "2026-07-06T09:00:00+00:00",
"created_at": "2026-07-06T09:00:00+00:00", "updated_at": "2026-07-06T09:00:00+00:00"
}
}
}
Errors: 422 missing name, subscription without billing_interval, or paid without
price · 404 no approved mentor profile.
2. Show / update / delete
GET /api/v1/mentor/communities/{slug} → { "data": { "community": { …incl. level_thresholds… } } }
PUT|PATCH /api/v1/mentor/communities/{id} (multipart to replace cover/icon)
DELETE /api/v1/mentor/communities/{id} → cascades members, posts, subscriptions, thresholds
Show resolves by
slug; update and delete resolve by{id}.
Update accepts name, slug, tagline, description, language, tags,
currency, cover, icon.
Pricing is changed via its own endpoint (below), not update.
Renaming no longer moves the slug. The slug is the community's public handle — it is in every link that has ever been shared and it is what
GET /public/communities/{slug}and every invite link resolve by. Changingnamenow leaves it untouched; changing the handle is a separate, deliberate edit that sendsslug.Creating a community still derives its first slug from the name (including
-1,-2suffixing on collisions). Only updates changed.
Update payload — every field optional (sometimes); send only what changes.
| Field | Type | Notes |
|---|---|---|
name |
string | Max 255. Does not touch slug |
slug |
string | 3–80 chars, ^[a-z0-9]+(?:-[a-z0-9]+)*$ — lowercase letters, digits, single hyphens between them. Must be unique across communities (the community's own current slug is always accepted) |
tagline |
string | Max 255 |
description |
string | Accepted — fills the About tab |
language |
string|null | Must be in the vocabulary from §18a. null clears it |
tags |
string[] | Slugs from the vocabulary in §18b. Replaces the whole set; max 20, no duplicates |
highlights |
string[] | About tab. Sent whole; max 20, each ≤ 255 chars. [] clears |
guidelines |
string[] | About tab. Sent whole; max 20, each ≤ 500 chars. [] clears |
gallery |
file[] | Additive — up to 10 images ≤ 5 MB each, appended to the gallery |
gallery_alt |
string[] | Alt text, parallel to gallery by index |
currency |
string | |
cover |
file | Image ≤ 5 MB — multipart/form-data, replaces the existing cover |
icon |
file | Image ≤ 2 MB — replaces the existing icon |
Slug errors are keyed on slug and 422: "The slug may only contain lowercase letters, numbers and single hyphens between them." for a malformed value, "That slug is already taken by another community." for a collision.
Discovery fields. language and tags are both drawn from server-held vocabularies —
an unrecognised value is a 422, never a silently created tag or language. An owner
picks tags, never invents them; only an admin extends the vocabulary (see
admin-community-tag-api.md). Populate the picker from
§18b, which is the same set this endpoint validates against.
-
"tags": []clears every tag; omitting the key leaves the set alone. -
An unknown slug fails the whole request — nothing in the payload is applied — and the error is keyed by the offending index so the form can mark the right chip:
{ "message": "That topic is not one we recognise.", "errors": { "tags.1": ["That topic is not one we recognise."] } } -
Both are sent as JSON normally, and as
language/tags[]form fields when a cover or icon is attached (multipart/form-datawith_method=put).
Request
{
"name": "Product Leaders Circle",
"tagline": "Playbooks from senior PMs",
"language": "English",
"tags": ["tech", "money"]
}
Response 200
{
"status": "success",
"message": "Community updated successfully.",
"data": {
"community": { "…community object, with the new name/slug/tagline…" }
}
}
Delete responds { "status": "success", "message": "Community deleted successfully.", "data": {} }.
2a. Privacy — access rules
GET /api/v1/mentor/communities/{id}/privacy
PUT|PATCH /api/v1/mentor/communities/{id}/privacy
Owner or admin — the
manage_settingspermission from §17a. Moderators and members get 403.
{
"status": "success",
"message": "Community privacy retrieved successfully.",
"data": {
"privacy": {
"visibility": "public",
"max_members": 500,
"requires_approval": true,
"join_questions_enabled": true,
"join_questions": ["Why do you want to join?"]
}
}
}
Payload — every field optional; a partial write changes only what it sends.
| Field | Type | Notes |
|---|---|---|
visibility |
enum | public | private — the same column §18 enforces |
max_members |
int|null | null = unlimited. Cannot be set below the live member_count |
requires_approval |
bool | Joins land as pending and wait on the queue — §9b |
join_questions_enabled |
bool | |
join_questions |
string[] | Max 5, each ≤ 255 chars |
max_members is enforced at the door. Once the community is full,
POST /mentee/communities/{id}/join returns 422 — "This community is full." — and
no subscription row is created, so a full community never takes a payment it cannot
honour. Setting the limit below the members already present is a 422 on the field rather
than a silent lock-out.
requires_approval gates joins. With it on, a join creates a pending membership
instead of admitting anyone, and the owner decides through the queue in §9b. An
invitation stands in for approval — the community already made that decision.
2b. Gallery — remove an image
DELETE /api/v1/mentor/communities/{id}/gallery/{media}
Gallery images are added through update (gallery[] + gallery_alt[], which append);
this is the way back out. {media} is the id from community.media[].
Responds 200 with the refreshed community object. A media id belonging to another
community — or to a different collection — is a 404, not a silent no-op.
3. Lifecycle: publish / unpublish / archive
PATCH /api/v1/mentor/communities/{id}/publish → status: published (sets published_at once)
PATCH /api/v1/mentor/communities/{id}/unpublish → status: draft
PATCH /api/v1/mentor/communities/{id}/archive → status: archived
No request body. Each returns 200 with the updated community and its own message —
Community published successfully. · Community unpublished successfully. ·
Community archived successfully.
{
"status": "success",
"message": "Community published successfully.",
"data": {
"community": {
"…community object…",
"status": "published", "status_label": "Published",
"published_at": "2026-07-06T09:30:00+00:00"
}
}
}
published_at is stamped on the first publish and kept thereafter, so an unpublish →
republish cycle preserves it.
What each status closes
status is the lifecycle axis — whether anyone can use the community at all. It outranks
both visibility and any membership row a user holds.
| status | Discover | Direct link (/public/communities/{slug}) |
Join | Member-only reads (feed, courses, members, leaderboard) |
|---|---|---|---|---|
draft |
never | 404 |
404 |
owner + admins only (403 for everyone else) |
published |
yes, if public |
200 |
allowed | active members |
archived |
never | 404 |
422 |
owner + admins only (403 for everyone else) |
A draft is never discoverable, whatever its visibility says — visibility only means anything
while status is published.
The member-only column is the part worth restating: an active membership row held over from when
a community was live does not reopen it. Owners and admins keep their access because
publishing is only possible from inside; moderators and members do not. The 403 message says
which case it is — This community is not published yet. or This community is no longer active.
4. Change pricing
PATCH /api/v1/mentor/communities/{id}/pricing
| Field | Type | Required | Notes |
|---|---|---|---|
pricing_type |
enum | yes | free · one_time · subscription |
billing_interval |
enum | conditionally | Required if subscription |
price |
decimal | conditionally | Required unless free |
currency |
— | ignored | Derived from the mentor's profile currency. Not accepted; a value sent here is stripped by validation. |
Request
{
"pricing_type": "subscription",
"billing_interval": "yearly",
"price": "180.00",
"currency": "USD"
}
Response 200
{
"status": "success",
"message": "Community pricing updated successfully.",
"data": {
"community": {
"…community object…",
"pricing_type": "subscription", "pricing_type_label": "Subscription",
"billing_interval": "yearly", "billing_interval_label": "Yearly",
"price": "180.00", "currency": "USD",
"pricing_changed_at": "2026-07-20T11:00:00+00:00"
}
}
}
Sets pricing_changed_at. On a free → paid transition (or a price rise), active members
receive a community.pricing_change notification with a 30-day effective_at. Existing
subscriptions keep their original price_snapshot (grandfathered).
Errors: 422 subscription without billing_interval, or paid without price.
4a. Coupons
GET /api/v1/mentor/communities/{community}/coupons → paginated
POST /api/v1/mentor/communities/{community}/coupons → 201
PUT /api/v1/mentor/communities/{community}/coupons/{coupon} → also partial
DELETE /api/v1/mentor/communities/{community}/coupons/{coupon}
Owner only — a coupon changes what members pay, which is
manage_payments, the one permission §17a reserves to the owner. Admins get 404, like pricing.
{
"id": "4a5b…",
"code": "LAUNCH50",
"discount_type": "percent",
"discount_type_label": "Percentage off",
"discount_value": "50.00",
"currency": null,
"max_redemptions": 100,
"redemptions_count": 12,
"per_user_limit": null,
"starts_at": "2026-09-01",
"expires_at": "2026-12-31",
"is_active": true,
"status": "live",
"status_label": "Live"
}
| Field | Type | Notes |
|---|---|---|
code |
string | Upper-cased on write; letters, numbers, - and _. Unique per community — 422 on a duplicate, but the same code may exist in another community |
discount_type |
enum | percent | fixed |
discount_value |
decimal-string | > 0; a percentage may not exceed 100 |
currency |
— | Derived from the community's currency for fixed, and null for percent. Not accepted. |
max_redemptions |
int|null | null = unlimited |
redemptions_count |
int | Read-only — writes are ignored. Moves on payment capture, never on checkout creation |
per_user_limit |
int|null | null = unlimited |
starts_at / expires_at |
date|null | YYYY-MM-DD; expiry may not precede the start |
is_active |
bool | The pause switch |
status |
enum | Server-derived, read-only — live · scheduled · expired · used_up · paused |
PUTserves partial updates — the active/paused switch sends only{"is_active": false}and everything else survives.statusis now sent rather than derived client-side. A client and a resolver that disagree about whether a code is live is exactly the bug that reads "it says live and it won't apply".- Dates are stored as timestamps — a start at 00:00:00 and an expiry at 23:59:59 — so a
code expiring "on the 31st" works all of the 31st. The API still takes and returns
YYYY-MM-DDhere, so nothing changes on the wire.
Redemption is built, but not wired to community joins. Coupons now live in a shared polymorphic
couponstable with one validate-and-redeem path, which course coupons already use end to end. Applying one at join time is acoupon_codefield on §9 join and nothing more; until that lands, a community code'sredemptions_countstays 0.
Errors: 404 not the owner (or no such coupon) · 422 duplicate code
on a fixed discount, percentage over 100, or an inverted date range
5. Members
GET /api/v1/mentor/communities/{community}/members → paginated (by XP desc)
PATCH /api/v1/mentor/communities/{community}/members/{member} → change role
POST /api/v1/mentor/communities/{community}/members/{member}/mute
DELETE /api/v1/mentor/communities/{community}/members/{member}/mute → unmute
POST /api/v1/mentor/communities/{community}/members/{member}/ban
DELETE /api/v1/mentor/communities/{community}/members/{member} → removes (status: removed)
Who may do this. Owner or admin — see Who may manage a community above.
Query (index): search (name/username), role (a slug from §17a), per_page.
The owner cannot be removed (422). Removal decrements member_count. Role changes
move a member between admin, moderator and member — owner is not assignable.
6. Level thresholds (gamification config)
GET /api/v1/mentor/communities/{community}/level-thresholds → { "data": { "level_thresholds": [ … ] } }
PUT /api/v1/mentor/communities/{community}/level-thresholds → bulk replace + recompute member levels
Payload (levels ≥ 1; level 0 is the implicit floor)
| Field | Type | Required | Notes |
|---|---|---|---|
levels |
array | yes | Bulk replace — omitted levels are deleted |
levels[].level |
int | yes | ≥ 1, unique within the payload. 0 is a 422 — the floor is not storable |
levels[].xp_required |
int | yes | Must strictly increase with level |
levels[].label |
string | no | Display name for the tier |
levels[].unlocks |
string | no | Free-text label for what the tier unlocks, ≤ 255 chars |
The floor is level 0. Every member stands on it at 0 XP; it needs no XP and so has no threshold row, which is why it can neither be sent here nor read back from §17c. A client rendering the full ladder synthesizes the floor itself —
level 0, xp_required 0— and cannot give it a custom label orunlocksstring.
Levels need not be contiguous. Only uniqueness and strictly-increasing XP are enforced,
so 1, 2, 4 is accepted and level 3 simply does not exist. A member crossing the 4th tier's
XP lands on level 4 directly.
Request
{
"levels": [
{ "level": 1, "xp_required": 100, "label": "Contributor" },
{ "level": 2, "xp_required": 250, "label": "Regular" }
]
}
Response 200
{
"status": "success",
"message": "Level thresholds updated successfully.",
"data": {
"level_thresholds": [
{ "id": "2f1a…", "level": 1, "xp_required": 100, "label": "Contributor" },
{ "id": "6c3d…", "level": 2, "xp_required": 250, "label": "Regular" }
]
}
}
XP must strictly increase with level, else 422. Saving recomputes every member's level.
unlocksis a free-text label the owner types, not a structured list. It is slated to be replaced by the owner-configurable unlock catalogue (see Deferred), which is why the members-facing ladder (§17c) is the better place to read a tier's meaning.
The members-facing view of the same rows is §17c, which adds member_percentage.
7. Post moderation
DELETE /api/v1/mentor/communities/{community}/posts/{post} → remove any member's post
POST /api/v1/mentor/communities/{community}/posts/{post}/pin → { "data": { "post": { … } } }
DELETE /api/v1/mentor/communities/{community}/posts/{post}/unpin
Who may do this. Despite the
/mentorprefix these are not owner-only: the owner (through their mentor profile) or any member whose role manages the community —owneroradmin. An admin need not be a mentor at all. Moderators are excluded from pinning and post removal; they police reports. A moderator, a plain member or a non-member all get 403 — not 404, so the UI can tell "not allowed" from "not there".The same rule governs deleting another member's post from the feed.
Verbs.
PATCH …/pinandPATCH …/unpinalso still answer, for clients built beforePOST/DELETEwere introduced. PreferPOST/DELETE; thePATCHpair will be dropped once nothing calls it.
No request body on pin/unpin. Each returns 200 with the updated post —
Post pinned successfully. / Post unpinned successfully.
{
"status": "success",
"message": "Post pinned successfully.",
"data": {
"post": {
"…post object…",
"is_pinned": true,
"pinned_at": "2026-07-20T12:00:00+00:00"
}
}
}
Unpinning sets is_pinned: false and pinned_at: null. Delete responds
{ "status": "success", "message": "Post removed successfully.", "data": {} }.
Pinned posts sort to the top of the feed.
7a. Gated course catalog (mirror)
GET /api/v1/mentor/communities/{community}/courses
The same endpoint as §16 — see there for the payload, gating and pricing rules. Gated on active membership, not ownership.
Mentee endpoints — participation
All require a Sanctum token. Feed / posts / comments / likes / me / courses require active
membership of the community, else 403.
8. My memberships
GET /api/v1/mentee/memberships → { "data": { "memberships": [ …member object w/ community… ] } }
"My Communities": the communities the caller is actually in. Concretely:
activemembership rows only. Apendingrow is not a membership — the applicant is waiting on a decision or on their payment — andrejected,left,removedandbannedare memberships that ended. None of them appear here.- Owned communities are included, through the
ownerrow seeded at creation, so a mentor browsing as a mentee sees the communities they run alongside the ones they joined. - Unpublished communities stay in the list when the caller owns them. Badge anything whose
community.statusis notpublished; the way in has to stay live for owners and admins, since publishing happens from inside.
Self-only, and membership-shaped. This returns membership records (level, XP, standing) for the caller. To list the communities another user belongs to, use
GET /api/v1/user/{username}/communities(Authentication API, endpoint 43) — it returns community summaries flagged withis_creator/member_role, and includes the ones they own.
9. Join a community (free or paid)
POST /api/v1/mentee/communities/{community}/join → 201
One endpoint for both pricing models. Every join creates a community_subscriptions
row with a price snapshot — free communities included — so free and paid share a single
code path. Only settlement differs:
| free | one_time / subscription |
approval required (§9b) | |
|---|---|---|---|
subscription |
created and active immediately, price_snapshot: null |
created pending |
null — none is issued |
membership |
granted now — the member object | null until payment |
the pending member object |
checkout_url |
null |
signed checkout URL | null |
pending_approval |
false |
false |
true |
ends_at |
null (never lapses) |
subscription → renewal window · one_time → null |
— |
Request body — only join_answers, and only when the community asks join questions:
| Field | Type | Notes |
|---|---|---|
join_answers |
string[] | Required when join_questions_enabled and the community has questions. One answer per question, parallel by index, each ≤ 1000 chars. 422 on a wrong count or a blank answer |
Pricing is read from the community and snapshotted server-side; there is nothing else to send.
Branch on pending_approval first, then checkout_url:
pending_approval: true→ the request is queued; show "awaiting approval".subscriptionandcheckout_urlare bothnull.- else
checkout_url !== null→ send them to pay. - else → they are in.
Response — free community 201
{
"status": "success",
"message": "You have joined the community.",
"data": {
"membership": {
"id": "1a2b…", "community_id": "9b6f…", "user_id": "7d2e…",
"member_role": "member", "member_role_label": "Member",
"status": "active", "status_label": "Active",
"level": 0, "xp": 0,
"joined_at": "2026-07-20T10:00:00+00:00"
},
"subscription": {
"id": "5e6f…", "community_id": "9b6f…", "user_id": "7d2e…",
"pricing_type": "free", "pricing_type_label": "Free",
"billing_interval": null, "billing_interval_label": null,
"price_snapshot": null, "currency": "USD",
"status": "active", "status_label": "Active",
"starts_at": "2026-07-20T10:00:00+00:00", "ends_at": null, "cancelled_at": null,
"created_at": "2026-07-20T10:00:00+00:00"
},
"checkout_url": null
}
}
Response — paid community 201
{
"status": "success",
"message": "Checkout started. Complete payment to activate your membership.",
"data": {
"membership": null,
"subscription": {
"id": "5e6f…", "community_id": "9b6f…", "user_id": "7d2e…",
"pricing_type": "subscription", "pricing_type_label": "Subscription",
"billing_interval": "monthly", "billing_interval_label": "Monthly",
"price_snapshot": "19.00", "currency": "USD",
"status": "pending", "status_label": "Pending",
"starts_at": null, "ends_at": null, "cancelled_at": null,
"created_at": "2026-07-20T10:00:00+00:00"
},
"checkout_url": "https://api.example.com/checkout/community/5e6f…?signature=…"
}
}
checkout_url is a temporary signed URL valid for 24 hours; re-calling join mints a fresh one.
For a paid community, open checkout_url → Pay activates the subscription and grants
membership through the same code path the free join runs inline; Cancel abandons it.
Repeating a paid join reuses the outstanding pending subscription (refreshing its price
snapshot) rather than creating a second one.
Errors: 422 you are already an active member, your request is already awaiting review,
the community is full, or you were banned (a ban is final — a member who merely left
may rejoin freely) · 404 no such community.
Against a community that is not open. Discover never offers these, but a direct link bypasses the list, so the endpoint refuses them itself:
| Joining a… | Response |
|---|---|
draft community |
404 Community not found. — it is not public yet, so it does not admit to existing |
archived community |
422 This community is no longer active. |
private, published community |
allowed — this is the invite-link path; private hides a community from Discover, it does not close it |
9a. Leave a community (free or paid)
DELETE /api/v1/mentee/communities/{community}/leave → 200
The mirror of join, and the only exit. Membership status becomes left and any live
subscription (active or pending) is cancelled in the same transaction, so a departed
member is never left holding access. member_count decrements.
{
"data": {
"membership": { "…member object, status: \"left\"…" },
"subscription": { "…subscription object, status: \"cancelled\"…" }
}
}
subscription is null when there was nothing live to cancel. Rejoining later is a plain
call to §9 — it reactivates the existing row and issues a fresh subscription.
Cancellation is immediate for both pricing models. A paid member forfeits the remainder of a paid period; there is no grace window until
ends_at. This is deliberate while settlement is faked — when a real gateway lands, the grace window belongs inCommunitySubscriptionService::leave().
Errors: 422 you are not an active member, or you own the community (an owner
cannot leave and orphan it — archive or delete the community instead, §2–§3).
9b. Join requests — the approval queue
GET /api/v1/mentor/communities/{community}/join-requests
POST /api/v1/mentor/communities/{community}/join-requests/{member}/approve
POST /api/v1/mentor/communities/{community}/join-requests/{member}/reject
Owner or admin —
manage_members, the same rule as the rest of member management.
Only relevant when the community has requires_approval on (§2a). An invitation stands
in for approval — the community already decided when it invited them, so an invited user
joins straight through.
The request is the membership row, held at
pending. That is what makes a pending applicant safe:pendingfails the one status check every member-only read funnels through, so they are refused the feed, courses, members, leaderboard, levels and roles (403 on each) without any endpoint needing to know approval exists. They are counted by nothing — notmember_count, nottotal_ranked.
Queue 200
{
"status": "success",
"message": "Join requests retrieved successfully.",
"data": {
"join_requests": { "data": [ {
"id": "1a2b…", "community_id": "9b6f…", "user_id": "7d2e…",
"status": "pending", "status_label": "Pending approval",
"user": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null },
"join_answers": ["To learn.", "Weekly write-ups."],
"requested_at": "2026-08-06T09:00:00+00:00"
} ], "…paginator…": {} },
"join_questions": ["Why do you want to join?", "What will you contribute?"]
}
}
- Paginated, oldest request first — the longest wait is dealt with first.
join_questionscomes back alongside so the queue can label the answers without a second call.join_answersis parallel to it by index.join_answersis on this endpoint only — it is written for the owner's eyes and never appears on a member object in §5 or §17b.
Approval is before payment
| free | paid | |
|---|---|---|
| On approve | member becomes active immediately, member_count increments |
member stays pending, now cleared to pay |
| How they get in | already in | they call §9 join again, which now returns checkout_url; payment activates them |
Nobody pays to be rejected — a paid community issues no subscription at request time, only once approved. The checkout link is deliberately not returned to the approving owner: it belongs to the applicant.
Rejecting sets the row to rejected. A rejection is not a ban — the applicant may
request again, which re-opens a fresh, undecided row.
Errors: 403 not owner or admin · 422 the request is no longer open (already decided),
or the community is full — capacity is re-checked at approval, not just at request
10. Subscription state
GET /api/v1/mentee/communities/{community}/subscription → current subscription (404 if none)
Read-only. Subscriptions are created by join (§9) and cancelled by leave (§9a).
11. My standing
GET /api/v1/mentee/communities/{community}/me
{
"data": {
"membership": { "…member object…" },
"next_level": 1,
"next_level_xp_required": 100,
"xp_to_next_level": 70
}
}
next_level is null at the top tier.
12. Feed
GET /api/v1/mentee/communities/{community}/feed
GET /api/v1/mentee/communities/{community}/feed?sort=recent&per_page=30
GET /api/v1/mentee/communities/{community}/feed?h=বাংলা
{community} is the community id (not the slug — slug resolution is mentor-show only, §2).
Query params
| Param | Type | Default | Notes |
|---|---|---|---|
sort |
enum | top |
top (engagement-ranked) · recent (newest first). Invalid value → 422 |
per_page |
int | 15 | 1–50. Out of range → 422 |
h |
string | — | Narrow the feed to one hashtag, with or without a leading #. 1–100 chars |
h matches the tag, not the text. A post reading "বাংলা আমাদের ভাষা" is not an h=বাংলা
hit — it never carried the tag. Matching is exact against the normalized name and mark-sensitive, so
h=বড় never returns posts tagged #বড. Tapping a hashtag in a community post should send h={tag}
here; the result keeps the feed's own ordering (pins first, then the requested sort).
Pinned posts always lead (is_pinned DESC) — that is the community's decision and it outranks
the caller's read history. Everything below is the requested ordering with an already-seen
handicap folded in: top ranks by the engagement hot score (same formula as the public feed,
see feed-api.md), recent is chronological.
pinned → requested sort, with seen posts handicapped
A seen post is demoted, not banished. One knob controls it — FEED_SEEN_AGE_MULTIPLIER
(default 2.5), read as "a seen post ages this much faster": top divides hot_score by
multiplier ^ gravity (/ 3.95 at the defaults), recent stretches the post's effective age by
the same factor. A genuinely hot post the caller has already read can still outrank weak new
content. A client that never calls the seen endpoint (§12a) sees no
change from the old ordering.
Response — a Laravel resource collection under posts, with the standard data / links /
meta structure:
{
"status": "success",
"message": "Community feed retrieved successfully.",
"data": {
"posts": {
"data": [
{
"id": "3c4d…",
"type": "text", "type_label": "Text",
"body": "Excited to be here!",
"reaction_count": 4, "comment_count": 2, "share_count": 0,
"my_reaction": "love", "is_seen": false,
"reactions": [ { "type": "love", "count": 3 }, { "type": "like", "count": 1 } ],
"hashtags": ["laravel", "বাংলা"],
"image_urls": [],
"video": { "url": "https://…/clip.mp4", "thumbnail": "https://…/clip-thumb.jpg", "mime": "video/mp4" },
"author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": false },
"community_id": "9b6f…", "is_pinned": true, "pinned_at": "2026-07-06T12:00:00+00:00",
"created_at": "2026-07-06T10:00:00+00:00", "updated_at": "2026-07-06T10:00:00+00:00"
}
],
"links": { "first": "…?page=1", "last": "…?page=4", "prev": null, "next": "…?page=2" },
"meta": { "current_page": 1, "from": 1, "to": 15, "per_page": 15, "last_page": 4, "total": 52, "path": "…", "links": [ … ] }
}
}
}
Each post carries the caller's my_reaction (or null) and the per-type reactions breakdown.
Poll posts carry their poll (with the caller's voted_by_me flags) in both the feed and
single-post reads. The video key is omitted on posts without a video, and comments is
omitted here — the feed does not eager-load them. Read a single post (§13) for its comment
thread, or page through them via the comments listing (§14).
Errors: 403 not an active member · 404 community not found · 422 invalid sort /
per_page.
12a. 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/mentee/communities/{community}/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
- Active membership required → 403 otherwise.
- Scoped to this community. Ids that do not belong to
{community}are dropped, so an id from somewhere the caller cannot read is never recorded. - Idempotent. A post already seen is not re-marked, so a repeat call returns
marked: 0. Retry freely. - Unknown ids are dropped, not rejected — a post deleted mid-scroll is not a client error.
- Per-caller, and never expires in this cut.
- More than 100 ids, an empty array, or a non-uuid → 422.
Reading it back. Every post on the feed listing (§12) carries is_seen (boolean). The key is
omitted on single-post reads, where the feed has not computed it — treat a missing value as
not applicable, not false.
⚠️ Paging: always echo seen_cursor
The feed pages by offset, and ordering depends on state the client is itself changing. Without a snapshot, marking page 1 seen reorders the set underneath the offset and page 2 skips content.
Every community-feed response carries a seen_cursor alongside posts. Pass it back as
seen_before on every subsequent page of the same session:
GET /api/v1/mentee/communities/{community}/feed?per_page=15
GET /api/v1/mentee/communities/{community}/feed?per_page=15&page=2&seen_before=2026-08-16T11:34:49.482+00:00
Drop it on an explicit refresh to pick up the newest seen state. Malformed value → 422. Full rationale in feed-api.md § Seen state.
Separate from the public feed. The two surfaces keep independent seen state: marking a community post never marks a feed post, and vice versa. The public-feed equivalent is
POST /api/v1/feed/posts/seen(see feed-api.md).
12b. Popular hashtags in this community
GET /api/v1/mentee/communities/{community}/hashtags
GET /api/v1/mentee/communities/{community}/hashtags?search=বাংলা&per_page=10
The tags this community's own posts carry, most used first. Tag rows are shared platform-wide,
the tallies are not: posts_count counts only posts inside {community}, so a community's trending
list reflects what its own members are writing. Active membership is required — the list summarises
the community's content and must not leak out of a private one.
Query params
| Param | Type | Default | Notes |
|---|---|---|---|
search |
string | — | Substring filter on the name; a leading # is ignored. Max 100 chars |
per_page |
int | 20 | 1–50 |
{
"status": "success",
"message": "Community hashtags retrieved successfully.",
"data": {
"hashtags": {
"data": [
{ "name": "বাংলা", "posts_count": 12, "created_at": "2026-07-06T10:00:00+00:00" },
{ "name": "laravel", "posts_count": 4, "created_at": "2026-07-06T09:00:00+00:00" }
],
"links": { "…" }, "meta": { "…" }
}
}
}
Pair it with GET …/feed?h={name} (§12) to open the tag.
Errors: 403 not an active member · 404 community not found · 422 invalid per_page.
13. Posts
POST /api/v1/mentee/communities/{community}/posts (multipart for attachments) → 201
GET /api/v1/mentee/communities/{community}/posts/{post}
PUT /api/v1/mentee/communities/{community}/posts/{post} (author only, else 403)
DELETE /api/v1/mentee/communities/{community}/posts/{post} (author only; owner uses moderation)
A post is text, a poll when a poll object is present, or a video when a
video_upload_id is present — same rules as the public feed.
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.
Create payload
| 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. Max 10000 |
attachments[] |
file[] | no | ≤ 10 files, ≤ 10 MB each. Mutually exclusive with video_upload_id |
video_upload_id |
uuid | no | A completed community_video chunked upload. Mutually exclusive with attachments and poll |
thumbnail |
file | no | Client-generated video frame, ≤ 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 |
Request — text-only post (application/json)
{ "body": "Excited to be here!" }
Request — poll post (application/json)
{
"body": "Vote below!",
"poll": {
"question": "Best day for the weekly call?",
"allows_multiple": false,
"expires_at": "2026-08-01T00:00:00Z",
"options": ["Monday", "Friday"]
}
}
Request — video post (multipart/form-data)
body=Here is my walkthrough
video_upload_id=1f4c…
[email protected]
Response 201
{
"status": "success",
"message": "Post created successfully.",
"data": {
"post": {
"id": "3c4d…",
"type": "text", "type_label": "Text",
"body": "Excited to be here!",
"reaction_count": 0, "comment_count": 0, "share_count": 0,
"my_reaction": null, "reactions": [],
"image_urls": [],
"author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": true },
"community_id": "9b6f…", "is_pinned": false, "pinned_at": null,
"created_at": "2026-07-20T10:05:00+00:00", "updated_at": "2026-07-20T10:05:00+00:00"
}
}
}
Update payload: body only (same 10000 cap) — attachments and video cannot be swapped after
creation. Returns 200 with message Post updated successfully. and the same post shape;
delete returns Post deleted successfully. with an empty data.
{ "body": "Excited to be here! (edited)" }
Creating a post awards post_created XP (capped 3/day). When the owner posts, all other
active members get a community.post_created notification.
Errors: 403 not an active member (create) or not the author (update/delete) · 422
missing body without a poll or video, invalid poll rules, or attachments/poll sent
alongside video_upload_id.
Video posts. Upload the file first via the Chunked Upload API (purpose
community_video), then create the post with the returnedvideo_upload_id. The response carries avideoobject (url,thumbnail,mime); the key is omitted on posts without a video.Thumbnail. Generated on the client and sent as the
thumbnailfield in the samemultipart/form-datarequest (same recipe as the public feed — see feed-api.md). The server never derives one, sovideo.thumbnailisnullwhenever the client omits it.
13a. Share / repost
POST /api/v1/mentee/communities/{community}/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 community post that itself reposted a feed post points
straight at that feed post.
A share can now land in three places, selected by destination + target_community_id:
| Destination | Payload | Response |
|---|---|---|
| This community (default) | nothing extra | community post object |
| Another community | target_community_id |
community post object |
| Your profile feed | destination: "feed" |
feed post object |
Payload
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | no | Optional quote text, max 10000. Omit (or null) for a plain repost |
destination |
enum | no | community (default) · feed |
target_community_id |
uuid | no | Share into another community instead of this one. Must exist, else 422. Ignored when destination: "feed" |
visibility |
enum | no | public (default) · connections. Only read when destination: "feed" |
Request
{ "body": "Worth a read" }
Permissions
Reading the original and writing the share are two separate checks, because they can involve two different places:
| Check | Against |
|---|---|
| Active membership — to read the original | This community |
| Contributor rights — active and not muted | The destination community |
Where to share to is answered by a picker endpoint:
GET /api/v1/mentee/communities/share-targets
→ { "data": { "communities": [ …community summary objects… ] } }
Every community the caller is an active member of, name-ordered.
Leaving the community is a separate permission → 403
A post may only leave the community it was written in if that community is published and
public. A private or unpublished community's posts cannot be carried onto a profile feed
or into another community — members wrote them for members, and the sharer has no authority to
republish them.
Resharing inside a private community is unaffected: staying put is not leaving.
The reverse direction — a public feed post into this community — is driven from the feed share route with
destination=community. Onlypublicfeed posts may be carried in; aconnections-only post is a 403. See feed-api.md §5.
XP and mentions follow the destination
XP is awarded in the community the share was posted into, and @mention eligibility is that
community's roster — so a cross-post can never let the sharer name members of the community the
post came from. A share to the profile feed awards no community XP at all.
Response 201
{
"status": "success",
"message": "Post shared successfully.",
"data": {
"post": {
"id": "8e9f…",
"type": "share", "type_label": "Share",
"body": "Worth a read",
"reaction_count": 0, "comment_count": 0, "share_count": 0,
"my_reaction": null, "reactions": [],
"image_urls": [],
"author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": true },
"shared_post": { "…the original post object, share_count: 1…" },
"shared_post_source": "community",
"community_id": "9b6f…", "is_pinned": false, "pinned_at": null,
"created_at": "2026-07-22T10:00:00+00:00", "updated_at": "2026-07-22T10:00:00+00:00"
}
}
}
Sharing feeds the original's hot score (share_count carries the same ×3 weight as the public
feed — see §12), on whichever surface the original lives. Deleting the original later nulls the
reference (shared_post disappears from the share) rather than deleting the share.
Errors: 403 not an active member of this community; not a contributor in the destination;
or the post may not leave its community · 404 post or target community not found · 422
unknown target_community_id.
13b. Polls
POST /api/v1/mentee/communities/{community}/posts/{post}/poll/vote body: { "option_ids": ["…"] } → { "data": { "poll": { … } } }
DELETE /api/v1/mentee/communities/{community}/posts/{post}/poll/vote → retracts the caller's vote(s)
Voting replaces any previous vote by the caller. Same rules as the public feed:
- 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.
Errors: 403 not an active member · 404 post not found in this community, or the post
has no poll · 422 poll rules above.
14. Comments
GET /api/v1/mentee/communities/{community}/posts/{post}/comments → paginated top-level comments (newest first, 3-comment subtree preview)
GET /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/replies → paginated descendants at any depth (oldest first)
POST /api/v1/mentee/communities/{community}/posts/{post}/comments → 201
DELETE /api/v1/mentee/communities/{community}/posts/{post}/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, capped at depth 50 — identical semantics to the main feed, which
feed-api.md documents in full. In short: every comment carries depth, root_id,
parent_author, is_deleted and mentions; the replies preview is the first 3 comments of the
whole subtree at any depth, flat; replies_count is the total of every descendant; and the
replies endpoint returns all descendants of the anchor, flat and paginated, with ?flat=0 to
re-nest within a page.
Reading a single post (§13) embeds the same shape, bounded to the 10 most recent top-level
comments — comment_count tells you the total. Both reads require active membership.
Response 200
{
"status": "success",
"message": "Comments retrieved successfully.",
"data": {
"comments": {
"data": [ { "…comment object…" } ],
"links": { "…" },
"meta": { "current_page": 1, "per_page": 15, "total": 2, "…": "…" }
}
}
}
Create payload
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | yes | Max 5000 |
parent_id |
uuid | no | A comment on this post, at any depth (max 50) |
mentioned_user_ids[] |
uuid | no | ≤ 20. Advisory only — the server re-parses body and that parse is authoritative |
Request
{ "body": "Welcome aboard!", "parent_id": null }
Response 201
{
"status": "success",
"message": "Comment added successfully.",
"data": {
"comment": {
"id": "7f8a…", "post_id": "3c4d…", "user_id": "7d2e…", "parent_id": null,
"depth": 0, "root_id": "7f8a…", "is_deleted": false,
"body": "Welcome aboard!",
"reaction_count": 0, "my_reaction": null, "my_reaction_exists": false, "reactions": [],
"author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "is_me": true },
"mentions": [], "parent_author": null,
"replies": [], "replies_count": 0,
"created_at": "2026-07-20T10:10:00+00:00", "updated_at": "2026-07-20T10:10:00+00:00"
}
}
}
Awards comment_on_post XP (capped 5/day), increments comment_count, and recomputes the post's
hot_score. Notifies the post author unless they are the commenter or are themselves mentioned —
see Mentions below. Delete returns 200 with Comment deleted successfully. and an empty
data.
Deleting a comment deletes its replies. A delete takes the whole subtree — every reply
beneath the comment, at every depth, along with their reactions and mentions. A reply has no
meaning without the comment it answers. comment_count drops by the number of comments actually
removed (the comment plus its subtree), floored at zero.
Changed behaviour. A comment with descendants used to be preserved as a tombstone (
is_deleted: true,body: null,author: null,user_id: null) so the subtree stayed readable, andcomment_countdropped by exactly 1.
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. Reacting to a tombstone is still a 422.
Errors: 403 not an active member (list/create) or not the author (delete) · 422 missing
body; a parent_id that does not exist or belongs to a different post; a parent already at
depth 50; reacting to a tombstone.
Mentions
@username in a comment or post body is parsed, resolved and returned as mentions[] — the same
verbatim-storage pipeline and the same parse rule as the main feed, documented in full in
feed-api.md.
Eligibility differs from the feed: active members of this community only. Not the author's connections, so nobody can be pulled into a private community by name. A handle outside that set is dropped silently and the body text stays as typed.
Mention notifications (community.post_mentioned, community.comment_mentioned) honour the
per-community mentions notification preference (§ notification preferences) — the switch has
existed since it shipped and is now live. One notification per recipient per comment: being
mentioned takes precedence over community.post_commented.
Suggestions — note the path is not under /mentee/:
GET /api/v1/communities/{community}/mentions?search=&per_page=8
→ { "data": { "users": { "data": [ { id, username, name, avatar_url } ], … } } }
Matches both name and username, ranked exact → prefix → substring; an empty search returns
the eligible set. Excludes the viewer, banned/inactive users, and members who set
hide_from_members_list. 403 for a non-member.
15. Reactions
Typed reactions (like · love · laugh · wow · sad · angry) on posts and comments,
reusing the public-feed reaction system. One reaction per user per target; posting again with a
different type changes it (no double-count).
POST /api/v1/mentee/communities/{community}/posts/{post}/reactions { "type": "love" } → { "data": { "reaction_count": n } }
DELETE /api/v1/mentee/communities/{community}/posts/{post}/reactions → { "data": { "reaction_count": n } }
POST /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/reactions { "type": "love" } → { "data": { "reaction_count": n } }
DELETE /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/reactions → { "data": { "reaction_count": n } }
Payload (POST only; DELETE takes no body)
| Field | Type | Required | Notes |
|---|---|---|---|
type |
enum | yes | like · love · laugh · wow · sad · angry. Invalid → 422 |
Request
{ "type": "love" }
Response 200 — the target's new total only; re-read the post for the reactions breakdown.
{
"status": "success",
"message": "Reaction saved successfully.",
"data": { "reaction_count": 5 }
}
DELETE returns the same shape with message Reaction removed successfully. Removing a reaction
you never made is a no-op, not an error — the count comes back unchanged.
The first reaction on a post awards reaction_received XP to the post author (not the
reactor); changing type or removing a reaction does not deduct XP, and comment reactions award
no XP. 403 if not an active member.
Breaking change. The former
POST/DELETE …/posts/{post}/likeendpoints and thelike_count/liked_by_mefields were removed. Use/reactionsand readreaction_count/reactions[]/my_reaction.
15a. Report
POST /api/v1/mentee/communities/{community}/posts/{post}/report → 201
POST /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/report → 201
POST /api/v1/mentee/communities/{community}/members/{username}/report → 201
Reports a post, a comment, or another member's profile to the shared moderation pipeline (the
same polymorphic feed_reports capture the public feed uses — capture-only in this cut; no
moderation surface yet). One report per user per target — a duplicate returns 409.
Reporting a member resolves the target by username (not the member UUID used elsewhere
in this doc) and requires:
- the reporter to be an active member of
{community}, and - the target to have some membership row in
{community}— not necessarily an active one, since someone already muted or banned is exactly who a member is most likely to be reporting.
The report is stored against the user globally, not against the community, so the same person reported from two communities — or from the public feed — is still one report per reporter.
Payload
| Field | Type | Required | Notes |
|---|---|---|---|
reason |
enum | yes | spam · harassment · hate_speech · violence · nudity · misinformation · other |
details |
string | no | Free text, max 2000 |
Request
{ "reason": "harassment", "details": "Targets another member repeatedly." }
Response 201
{ "status": "success", "message": "Report submitted successfully.", "data": {} }
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.
Errors: 403 not an active member, or the reported member does not belong to this community ·
404 target not found in this community · 409 already reported by you · 422 invalid reason,
or the target is the caller's own content.
16. Gated course catalog
GET /api/v1/mentee/communities/{community}/courses
GET /api/v1/mentor/communities/{community}/courses ← identical response
Available on both surfaces — same controller, same gating, same payload. Use whichever
matches the caller's context; a mentor browsing a community they own or belong to can stay on
the mentor surface instead of crossing to /mentee.
The mentor route is gated on active membership, not ownership. Owners pass because creating a community writes an
ownermembership row for the mentor's user.
Lists the community's published courses — every course this community carries, including those shared with other communities — with the member's resolved price:
{
"data": {
"courses": [
{
"id": "…", "title": "…", "slug": "…",
"visibility": "community_only", "total_lessons": 12, "thumbnail_url": null,
"my_pricing": { "base_price": "99.00", "list_price": null, "price": null, "currency": "USD",
"is_free": true, "audience_type": "specific_community",
"audience_type_label": "Specific Community",
"rate": { "…": "…" }, "offer": null, "savings": "0.00", "savings_pct": 0 }
}
]
}
}
⚠️ Changed:
courses[].community_idwas removed — the endpoint is already scoped to one community.my_pricingis now resolved in this community's context, so a course shared with another community quotes this community's price rather than the best across your memberships.
⚠️ Changed again:
my_pricinghere is now the same block every other course payload carries — seemy_pricing. It is always present (nevernull), it spells the keyaudience_type_labelrather thanaudience_label, and it carriesbase_price,list_price,offerandsavingson top of what it used to.
See Course gating below for how pricing and module locking resolve. 403 if not a member.
17. Notifications (in-app)
GET /api/v1/notifications → paginated + "unread_count"
PATCH /api/v1/notifications/{id}/read → mark one read
POST /api/v1/notifications/read-all → mark all read
Each entry: { id, type, data, read_at, created_at }. Community-domain type values:
community.post_created · community.post_commented · community.post_mentioned ·
community.comment_mentioned · community.pricing_change · community.level_up ·
community.join_request_approved · community.join_request_rejected. Full catalog across all
domains: notification-system.md.
No request body on either mutating call. Both return 200 with an empty data:
{ "status": "success", "message": "Notification marked as read.", "data": {} }
{ "status": "success", "message": "All notifications marked as read.", "data": {} }
PATCH …/{id}/read on an unknown or someone else's notification → 404.
Member endpoints — any active member
Routes under /api/v1/communities/{id}/… are neither mentor- nor mentee-scoped: any
active member may call them, the owner included. A non-member gets 403, never an
empty list.
| § | Endpoint | |
|---|---|---|
| 17a | GET …/roles |
role vocabulary + permission matrix |
| 17b | GET …/members |
members directory |
| 17c | GET …/levels |
level ladder + member_percentage |
| 17d | GET …/leaderboard |
7d / 30d / all-time in one response |
| 17e | GET·POST·DELETE …/invites |
invitations |
| 17f | GET·PUT …/notification-preferences |
per-community switches |
| 17g | GET·PUT …/member-privacy |
who may reach me here |
| 17h | GET …/courses/wishlist |
saved courses |
| 17i | GET …/unlocks |
the XP catalogue (owner saves via /mentor) |
17a. Roles
GET /api/v1/communities/{community}/roles
The role vocabulary and its permission matrix. Backs both the Roles settings screen and the Members tab role filter, so neither has to carry a local copy that can drift from what the API enforces.
Response 200
{
"status": "success",
"message": "Community roles retrieved successfully.",
"data": {
"roles": [
{
"slug": "owner",
"name": "Owner",
"description": "Created the community and holds every permission, including pricing and payouts.",
"is_system": true,
"is_assignable": false,
"permissions": {
"manage_settings": true,
"manage_members": true,
"moderate_content": true,
"manage_courses": true,
"manage_payments": true
}
}
]
}
}
All four built-in roles are returned in descending authority — owner, admin,
moderator, member.
is_systemistruethroughout: every role is built in, none was created by an owner. Custom roles are not supported.is_assignableisfalseonly forowner— ownership is not transferable, so it never appears in an "assign role" dropdown.
Permission matrix — derived from the same predicates the API enforces, not a parallel table that can drift from them:
| permission | owner | admin | moderator | member | backed by |
|---|---|---|---|---|---|
manage_settings |
✔ | ✔ | canManage() |
||
manage_members |
✔ | ✔ | canManage() |
||
moderate_content |
✔ | ✔ | ✔ | canModerate() |
|
manage_courses |
✔ | ✔ | canManage() |
||
manage_payments |
✔ | owner only |
Errors: 403 not an active member · 404 no such community
17b. Members directory
GET /api/v1/communities/{community}/members
The Members tab as any active member sees it. The owner-scoped listing at §5 serves the same roster for moderation.
Query: search (name/username), role (a slug from §17a), per_page (1–50).
Paginated, ordered by XP descending. Rows are member objects.
Errors: 403 not an active member
17c. Levels
GET /api/v1/communities/{community}/levels
The level ladder as members read it — the same rows the owner edits through §6, plus the share of members sitting at each tier.
{
"status": "success",
"message": "Community levels retrieved successfully.",
"data": {
"levels": [
{ "id": "2f1a…", "community_id": "9b6f…", "level": 1, "xp_required": 100,
"label": "Contributor", "unlocks": "Direct messages", "member_percentage": 38 }
]
}
}
Level 0 — the floor — is not in this list, and the list is empty for a community whose owner has configured no tiers. The floor is where every member starts at 0 XP; it has no stored row, so the client renders it as
level 0, xp_required 0with a label of its own choosing. The first rung the server returns is level 1.
member_percentage is the share of active members at that level, as a whole percentage.
It is computed only on this endpoint — the owner-facing §6 omits it.
The percentages can sum to less than 100. Members sitting on the floor are counted in
the denominator but have no row to be reported against, so a community where everyone is
still at level 0 returns every rung at 0. Derive the floor's share as
100 − sum(member_percentage) if you need to show it.
Errors: 403 not an active member
17d. Leaderboard
GET /api/v1/communities/{community}/leaderboard
Query: per_page (default 10).
All three ranges come back in one response — the tab renders them side by side, so splitting them into three calls buys nothing.
{
"status": "success",
"message": "Leaderboard retrieved successfully.",
"data": {
"leaderboard": {
"7d": { "range": "7d", "entries": [ … ], "me": { … }, "total_ranked": 41 },
"30d": { "range": "30d", "entries": [ … ], "me": { … }, "total_ranked": 58 },
"all": { "range": "all", "entries": [ … ], "me": { … }, "total_ranked": 62 },
"updated_at": "2026-08-06T09:00:00+00:00"
}
}
}
Entry object
{
"id": "1a2b…", "rank": 3, "points": 240, "level": 4,
"has_streak": true, "is_me": false,
"user": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null }
}
meis the caller's own row, present whether or not they made the page — so the tab can always show "you are #17" without a second request.nullonly when the caller has no membership row at all.rankisnullfor a member who earned nothing in the range — unranked, not last.pointsis XP earned within the range, not lifetimexp.has_streakrequires 3 consecutive active days.total_rankedcounts everyone with points in the range, not the page.
Errors: 403 not an active member
17e. Invites
GET /api/v1/communities/{community}/invites → paginated
POST /api/v1/communities/{community}/invites → 201
DELETE /api/v1/communities/{community}/invites/{invite} → revoke
Any active member may invite. Owners and admins additionally see and revoke everyone's; an ordinary member sees and revokes only their own.
Create payload — exactly one of the two, never both and never neither:
| Field | Type | Notes |
|---|---|---|
email |
string | Required without user_id; prohibited alongside it |
user_id |
uuid | Required without email; must exist |
Invite object
{
"id": "4d5e…", "community_id": "9b6f…",
"email": "[email protected]",
"user": null,
"status": "pending", "status_label": "Pending",
"invite_url": "https://app.example.com/community/product-leaders-circle?invite=…",
"invited_by": { "id": "7d2e…", "username": "janedoe", "name": "Jane Doe", "avatar_url": null },
"expires_at": "2026-08-20T09:00:00+00:00",
"accepted_at": null,
"created_at": "2026-08-06T09:00:00+00:00"
}
Invite status: pending · accepted · revoked · expired
statusis reported against the clock, so a lapsed invite reads asexpiredwithout any sweep job having rewritten the row.
Invites live 14 days (invite_ttl_days). invite_url points at the frontend
({frontend_url}/community/{slug}?invite={token}) and works for a private community,
which is the point of §18's visibility carve-out.
Errors: 403 not an active member · 422 duplicate pending invite, or revoking one
that is not yours · 404 no such invite
17f. Notification preferences (per community)
GET /api/v1/communities/{community}/notification-preferences
PUT /api/v1/communities/{community}/notification-preferences
Per-community switches, distinct from the global notification feed in §17.
{
"status": "success",
"message": "Notification preferences retrieved successfully.",
"data": {
"notification_preferences": {
"new_posts": true, "comments": true, "mentions": true,
"courses": true, "events": true, "digest": false
}
}
}
PUTis a partial update — send only the switches that changed; the rest keep their values.- All six always come back, defaults included, for a member who has never opened the
screen (
digestis the only one defaulting tofalse). Reading never writes.
Errors: 403 not an active member
17g. Member privacy (per community)
GET /api/v1/communities/{community}/member-privacy → the caller's own settings
PUT /api/v1/communities/{community}/member-privacy → partial update
Who may reach the caller inside this community. Scoped to the caller — there is no route to read or write anyone else's — and it never touches their global profile settings.
{
"status": "success",
"message": "Member privacy retrieved successfully.",
"data": {
"member_privacy": {
"chat_access": "everyone",
"profile_visibility": "everyone",
"hide_from_members_list": false
}
}
}
Audience: everyone · admins · nobody — admins means the owner, admins and
moderators (canModerate(), the same predicate the moderation surfaces use).
PUTis a partial update, like §17f — send only what changed.- All three always come back, defaults included, for a member who has never opened the
screen (
everyone/everyone/false). Reading never writes. - Settings are per membership row: the same person in two communities holds two independent sets.
Managers are always admitted, and so is the member themselves. Even
nobodydoes not put a member beyond the reach of the people who moderate them — otherwise a member could make themselves unanswerable to the owner. Readnobodyas "no ordinary member".
Errors: 403 not an active member · 422 value outside the audience vocabulary
The resolved flags on member objects
The members list cannot evaluate someone else's settings, so the API resolves them per viewer. Every member object in §17b and §5 carries:
"can_chat": true, // may the VIEWER message this member
"can_view_profile": true // may the VIEWER open their profile
Both are absent on listings with no viewer context; treat an absent flag as true.
Enforcement
chat_access is enforced, not merely advertised. POST /chat/users/{user}/messages and
POST /chat/threads/{thread}/messages (1:1 threads only) return 403 with a surfaceable
message — e.g. "This member only accepts messages from admins."
- Judged across every community the two share: accepting anywhere is enough, so tightening one community does not cut off a conversation another one permits.
- Two users who share no community are not governed by this rule at all.
- The thread endpoint is checked too, so a conversation opened before the recipient tightened their settings does not remain a way around them.
hide_from_members_list
When true, the member is omitted from §17b for ordinary members — owners, admins and
moderators still see them, and a member is never hidden from themselves. They are also
omitted from the leaderboard's entries[] (§17d).
They still count in member_count and total_ranked, and still get their own me
entry. Hiding is applied after ranking, so it changes who is listed, never anyone's
position — which means a leaderboard page can come back shorter than per_page when
someone on it has opted out.
17h. Course wishlist
GET /api/v1/communities/{community}/courses/wishlist → paginated, community-scoped
GET /api/v1/courses/wishlist → paginated, every saved course
POST /api/v1/courses/{course}/wishlist → { "data": { "is_wishlisted": true } }
DELETE /api/v1/courses/{course}/wishlist → { "data": { "is_wishlisted": false } }
The wishlist is per user and global to the course — a community only narrows which saved courses the tab lists. That is why the writes are not community-scoped: saving a course once saves it everywhere it appears.
The unscoped listing and the write endpoints are documented in full in
Course API §20a–20c; {course} there also accepts a course slug, so the
public course page can toggle the heart. This section covers only the community-narrowed
listing.
The listing returns the same course objects as §16, carrying the member's resolved
pricing and the is_enrolled / is_wishlisted flags the tabs split on.
Both writes are idempotent — saving twice is not an error, and the response always reports the resulting state.
Errors: 403 not an active member (listing only) · 404 no such course
17i. Unlocks — the XP catalogue
GET /api/v1/communities/{community}/unlocks → any active member
PUT|PATCH /api/v1/mentor/communities/{community}/unlocks → owner or admin
Every community starts from the same catalogue of activities; the owner sets the points, an optional daily cap, and the level that gates each one, and can switch any of them off. Members read the same payload to learn how to earn points.
{
"status": "success",
"message": "Community unlocks retrieved successfully.",
"data": {
"unlocks": [
{
"key": "post_to_feed",
"name": "Post to feed",
"description": "Publish a discussion post in the community feed.",
"category": "feed",
"points": 10,
"daily_limit": 3,
"required_level": null,
"is_active": true,
"unit": null,
"default_points": 10
}
]
}
}
| Field | Owner-configurable | Notes |
|---|---|---|
key |
Stable — points are awarded against this, not the name | |
name · description · category |
Shared copy; category is feed | courses | community |
|
points |
✔ | ≥ 0. Zero is legitimate — the activity still logs, it just moves nobody |
daily_limit |
✔ | ≥ 1, or null for uncapped |
required_level |
✔ | Must be a level on this community's ladder, or null for ungated. 0 is always valid — it is the floor, so it gates to "any member" |
is_active |
✔ | Off awards nothing |
unit |
Qualifier, e.g. "per 10 minutes watched" |
|
default_points |
The catalogue default, for the settings screen's Reset button |
Rows are seeded on first read, so a community created before this existed behaves exactly like a new one — the catalogue defaults apply until an owner saves.
Saving
{ "unlocks": [
{ "key": "post_to_feed", "is_active": true, "points": 5, "daily_limit": 3, "required_level": null },
{ "key": "share_post", "is_active": false }
]}
- Returns the full updated catalogue, in the same shape as the
GET. - Only the listed keys change; unlisted ones keep their config, and each field is optional within a listed key.
- An unknown key is a 422, never a row created on the fly.
- A
required_levelnot on the ladder is a 422 — a gate pointing at a level that does not exist would lock the activity away for good. Level 0 is exempt: every community has a floor, so it is accepted even by a community with no tiers configured at all.
XP awarding reads this config
CommunityXpService is no longer a fixed table. On every award it looks up the community's
row and, in order:
- skips entirely if
is_activeis false, - awards
points— the owner's value, not the catalogue default, - respects
daily_limitfor that member that day (UTC), - skips if the member's level is below
required_level, - dedupes per source where the activity is once-per-thing (see the catalogue table under Enums).
Two communities' economies are entirely independent.
Levels. level_thresholds[].unlocks remains the free-text string the owner types and is
round-tripped untouched. The levels editor is expected to derive its chips from the unlocks
whose required_level matches that level.
Errors: 403 not an active member (GET) / not owner or admin (PUT) · 422 unknown key,
bad daily_limit, or a required_level off the ladder
Public endpoints — discovery
No authentication required. A Sanctum token is optional — when supplied, the show
response is personalised for the caller (see is_member / subscription below).
18. Browse / show
GET /api/v1/public/communities → paginated (published + public only)
GET /api/v1/public/communities/{slug} → published community by slug (404 otherwise)
Query (browse)
| Param | Type | Notes |
|---|---|---|
search |
string | Matches name, tagline and description |
tag |
string | One slug — what a chip tap sends |
tags |
string | Several slugs, comma-separated: tags=tech,music. OR-matched — a community carrying any of them qualifies, and one carrying two still appears once. Takes precedence over tag when both are sent |
pricing_type |
string | free · one_time · subscription |
price_min / price_max |
numeric | Free communities price as 0, so price_min=0 includes them. price_max must be ≥ price_min |
languages |
string | Comma-separated, OR-matched: languages=English,Spanish |
sort |
string | recommended (default) · name · price_low · price_high · rating · members. popular and newest predate this vocabulary and still work |
per_page |
int | 1–50 |
Every filter is applied in SQL, so a narrowed set stays narrowed across page boundaries
— meta.total reflects the filter, not the unfiltered table. Do not re-filter client-side.
Unknown tag slugs are a 422, on both params. tags names the offending members back
rather than failing anonymously:
{ "message": "Unrecognised community tag: not-a-real-tag.",
"errors": { "tags": ["Unrecognised community tag: not-a-real-tag."] } }
Visibility is enforced. Browse returns published and
publiccommunities only. Aprivatecommunity is absent from browse, from the Discover page and from global search, and it does not count toward the price range in §18a — butGET /public/communities/{slug}still resolves it, so an invite link keeps working. Members and owners continue to reach their own private communities through the membership and ownership arms of search.
Browse returns what the viewer could still join
For a signed-in caller, browse is scoped to communities with a Join left to offer. All four of these hold, and all four are applied in SQL:
status = publishedvisibility = public- the caller holds no active membership — this covers communities they own, since the
owner holds an
ownerrow from creation - the caller holds no
pendingrow — they have already applied, or are mid-payment, and offering "Join" again would send them somewhere they have already been
A banned row excludes the community too: a ban is final, so a join would only 422.
left, rejected and removed rows do not exclude it — those users may rejoin, so the
community is still genuinely on offer.
Because the exclusions are SQL rather than a post-filter, meta.total, last_page and
"Load more" all describe the rows you actually received. Do not re-filter client-side — a
client-side drop leaves the paginator counting rows that are not on the page, which is how
page 1 comes back visually empty with last_page: 1.
Guests are subject to rules 1 and 2 only — a logged-out visitor holds no memberships to
exclude — and their rows carry is_member: false and my_membership: null.
Guests get less than members.
level_thresholdsis withheld from a guest or non-member on the show response — see the Community object note. Post content, member lists and leaderboard data are not reachable at all (each 403s).
Optional auth on show: if a valid Authorization: Bearer {token} is sent, the
community object gains two booleans scoped to the caller:
is_member—truewhen the caller has an active membership of the community, the unified access flag for both free and paid communities. It covers any route in: a free join, a paid subscription, or being the owner.falsefor an authenticated non-member.subscription— present whenever the caller holds an active subscription, which since every join issues one now includes free members (pricing_type: "free",price_snapshot: null). Omitted for non-members and for owners, who hold membership without a subscription.
For anonymous callers both are omitted entirely. Browse never includes them.
Use
is_memberfor "does this user have access". To single out a paying subscriber, checksubscription.pricing_type !== "free"— the presence of the object alone no longer implies payment, since free joins carry a zero-price subscription too.
200 (show, authenticated paid subscriber excerpt)
{
"data": {
"community": {
"id": "9b6f…", "name": "…", "slug": "…", "status": "published",
"pricing_type": "subscription", "price": "19.00", "member_count": 42,
"is_member": true,
"subscription": {
"id": "1c2d…", "status": "active", "status_label": "Active",
"pricing_type": "subscription", "billing_interval": "monthly",
"price_snapshot": "19.00", "currency": "USD",
"starts_at": "2026-07-19T10:00:00+00:00", "ends_at": "2026-08-19T10:00:00+00:00"
}
}
}
}
Errors: 404 not found / not published
18a. Filter vocabularies
GET /api/v1/public/community-filters
Option sets backing the Discover chip row and filter sheet. Every option carries the query
parameter and the value to send, so a generic filter component builds ?{param}={value}
without knowing which filter it is rendering.
{
"status": "success",
"message": "Community filters retrieved successfully.",
"data": {
"filters": {
"tags": {
"param": "tag", "multiple": true, "multiple_param": "tags",
"options": [
{ "param": "tag", "value": "tech", "label": "Tech",
"slug": "tech", "name": "Tech", "icon": "cpu" }
]
},
"languages": {
"param": "languages", "multiple": true, "multiple_param": "languages",
"options": [
{ "param": "languages", "value": "English", "label": "English",
"slug": "English", "name": "English" }
]
},
"price": {
"param": "price", "min_param": "price_min", "max_param": "price_max",
"min": 0, "max": 199
},
"pricing_type": {
"param": "pricing_type", "multiple": false,
"options": [ { "param": "pricing_type", "value": "free", "label": "Free",
"slug": "free", "name": "Free" } ]
},
"sort": {
"param": "sort", "multiple": false, "default": "recommended",
"options": [ { "param": "sort", "value": "recommended", "label": "Recommended",
"slug": "recommended", "name": "Recommended" } ]
}
}
}
}
Reading an option
| Key | Meaning |
|---|---|
param |
The query parameter this option belongs to |
value |
What to send for it |
label |
What to show the user |
slug / name |
Aliases of value / label, kept for callers written against the older payload. Prefer value / label |
icon |
Tags only — an icon key the admin set, or null |
Reading a filter
| Key | Meaning |
|---|---|
param |
The single-select parameter |
multiple |
Whether several values may be sent at once |
multiple_param |
The parameter that accepts a comma-separated set — tags for the tag filter, languages for languages |
min_param / max_param |
price only: a range spends two parameters, so param alone cannot drive it. Send price_min and price_max |
default |
sort only: what the server applies when sort is absent |
Both vocabularies are complete sets, not values currently in use — a settings form that could only pick from what already exists could never introduce a new tag or language.
tags.optionsis the whole vocabulary, whether or not any community carries a given tag. It is admin-managed at runtime — seeadmin-community-tag-api.md— so treat it as data, never as a hardcoded list. Readiconper option rather than mapping slugs client-side: a slug an admin added yesterday has no entry in a map shipped last release.languages.optionsis the full allowed set, served fromconfig('constants.community.languages')— currently 20 entries: English, Spanish, French, German, Portuguese, Italian, Dutch, Russian, Turkish, Arabic, Hindi, Bengali, Urdu, Chinese, Japanese, Korean, Indonesian, Vietnamese, Thai, Swahili. The same list validateslanguageon §2, so a value outside it is a 422. Extending the list is a one-line config change.price.min/price.maxspan published public communities only, so the slider's range covers what the range can actually surface.
Response is cached (community_filters_ttl), but an admin editing the tag vocabulary
invalidates it immediately — a new tag is filterable on the next request, not an hour
later.
⚠️ Changed:
languages.optionswas an array of plain strings and is now an array of option objects. Read.value(or.slug) where you previously read the string. The other option sets only gained keys.
Picking tags on a form? Use §18b instead. This endpoint is the discover-side read and carries filter metadata a picker has no use for.
18b. Tag vocabulary — the tag picker
GET /api/v1/public/community-tags
The tags an owner may attach to a community, for the create and General settings forms (§1, §2). No authentication required. Alphabetical, never paginated — it is a chip row, not a list.
{
"status": "success",
"message": "Community tags retrieved successfully.",
"data": {
"tags": [
{ "slug": "health", "name": "Health", "icon": "heart-pulse" },
{ "slug": "hobbies", "name": "Hobbies", "icon": "palette" },
{ "slug": "money", "name": "Money", "icon": "wallet" },
{ "slug": "music", "name": "Music", "icon": "music" },
{ "slug": "spirituality","name": "Spirituality","icon": "sparkles" },
{ "slug": "sports", "name": "Sports", "icon": "trophy" },
{ "slug": "tech", "name": "Tech", "icon": "cpu" }
]
}
}
| Field | Type | Notes |
|---|---|---|
slug |
string | What you send in tags[] on §1/§2 |
name |
string | What you show |
icon |
string|null | Icon key the admin set; null when unset — fall back to your own map |
The vocabulary is dynamic. It ships seeded with Hobbies, Music, Money, Spirituality, Tech, Health, Sports, Self-improvement and Relationships, and an admin adds to or removes from it at runtime. Never hardcode the list: fetch it, and expect it to differ between environments and over time.
This is the same set that validates tags[] on write, so anything the picker offered
will be accepted. Deleting a tag detaches it from every community that carried it — the
communities themselves are untouched, they simply lose that chip.
Cached (community_tags_ttl) and invalidated the moment an admin edits the vocabulary.
18c. A user's communities — see the Auth API
GET /api/v1/user/{username}/communities
Lives on the profile surface, not this one — documented in
auth-api.md. Noted here because it returns communities: the standard
summary shape plus is_creator and member_role, so owned and joined communities can
share one list and still be told apart.
Web endpoints — fake checkout (signed, no login)
GET /community-checkout/{subscription} → checkout page (Pay / Cancel)
POST /community-checkout/{subscription}/pay → activates subscription + membership
POST /community-checkout/{subscription}/cancel → abandons the pending subscription
Reached only via the signed checkout_url returned by the join endpoint (§9). Stands in for
the deferred real payment/subscription billing flow. Pay runs the same activation the free
join performs inline, so both pricing models converge here.
Course gating
The community module activates the dormant hooks in the course module. A mentor establishes the
link from the course side (see the Course API): they set the course's community_ids on
create/update, and add per-audience course_pricing rows scoped to a community they own. Both
must reference communities the mentor owns. A course may be carried by several communities
and is priced independently in each. Given such a course:
- Pricing resolution (
CoursePricingService::resolveFor) — resolves in a community's context: browsing or enrolling through a community quotes that community's price, not the best price the member holds elsewhere. Within one community, precedence isspecific_community→community_member→public(ties broken by the cheapest option). Non-members, anonymous callers, and contexts with no community resolve to the public price. - Visibility —
community_onlycourses stay hidden from public browse and are surfaced through endpoint 16; enrolling in one requires active membership of any carrying community. - Module locking — a module's
unlock_at_levellocks it until the learner reaches that level in the community they enrolled through (course_enrollments.community_id), since levels are per-community. In the learner content payload, locked modules are flaggedis_locked: trueand their lessons are withheld.unlock_at_levelis rejected on a course no community carries.unlock_at_level: 0gates at the floor — open to every member, still shut to anyone who is not one. A public or standalone enrollment carries no community and so no level, leaving every gated module locked regardless of the learner's standing elsewhere. - XP — progress XP lands in the enrolling community only, never across every carrying one.
Deleting a community unlinks its courses. A community_only course left carried by nothing
is automatically downgraded to unlisted, so it stays reachable by direct link instead of
becoming visible to nobody. Courses carried by another community are unaffected.
XP & levelling model
action ─► CommunityXpService.award (per-member row locked)
│ daily cap? once-per-source? → skip
▼
community_xp_events (immutable ledger) + member.xp += value
│
└─► level recomputed from community_level_thresholds
- The ledger (
community_xp_events) is the source of truth for daily caps and once-per-source dedupe;community_members.xpis a denormalized running total. - Daily-cap buckets are keyed on the UTC award date.
- A member's
levelis the highest threshold whosexp_required ≤ xp, or 0 — the floor — when they have crossed none. A community with no thresholds leaves every member at 0. - The floor is 0, not 1. It has no threshold row, so it never appears in §6 or §17c and cannot be labelled; clients render it themselves. Configured tiers start at 1.
levelis never null on a member, but "no level" is a distinct state from the floor: a user who is not an active member has no level in that community at all, and every gated module and unlock stays shut to them — including one gated at level 0.
Deferred / cross-phase (not in this cut)
Every endpoint the frontend contract asked for now ships. What is left are the loose ends those endpoints leave behind:
| Item | Status | Notes |
|---|---|---|
| Coupon redemption | Mechanism built, join not wired | Coupons are polymorphic now and the validate/redeem path ships with course coupons. A community code still has nothing consuming it — that is a coupon_code field on §9 join |
| Notifying an applicant of an approval or rejection | Not built | The queue (§9b) sets the state and sends nothing; an applicant finds out by revisiting |
unlock_items — a derived {key, name} array on level_thresholds[] |
Not built | unlocks stays the free-text string, round-tripped untouched (§17i). The derived array is a payload addition waiting on a client that wants it |
?status= filter on /mentee/memberships |
Deferred | The list is active-only (§8); other states are reachable through their own surfaces |
| Lighter list payloads | Deferred | /mentor/communities and /mentee/memberships rows still carry the whole community object (§2), not a summary. Shape-compatible with detail, at the cost of payload size |
Decisions taken rather than asked about, recorded so they are easy to overrule:
nobodyon a member-privacy audience means "no ordinary member" — managers and the member themselves are always admitted (§17g).- Moderators cannot pin or remove posts — owner and admin only. Moderators police reports (§7).
languageis validated against a fixed 20-entry vocabulary (§18a); extending it is a seed change, not a schema one.- A coupon is redeemed by a
coupon_codeon the join call, not by a separate validate endpoint — matching how course coupons already work.
Cross-phase:
| Item | Present | Unblocks with |
|---|---|---|
| Events calendar + "attend event" XP (+50) | — | Future community cycle |
| Report moderation surface (list/resolve reports, hide/remove content) | reports captured in feed_reports (§15a) |
Future moderation cycle |
| Real subscription billing / renewals / dunning | community_subscriptions.payment_id (unconstrained) |
Payments module |
| Queued notification fan-out | database notifications (synchronous) | When a queue worker is provisioned |
| Email / push notification channels | database channel only | Phase 10 — Notifications |