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.
- 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 (requires an approved mentor profile; ownership enforced by scoping → 404 otherwise). 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 500 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) return field errors in data.
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, me, courses) |
| 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) |
| 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)
Member role: owner · member
Member status: active · left · removed · banned (only active may participate)
left is a self-exit (§9a) and may rejoin; removed is owner moderation; banned is final —
a banned user is refused by join.
Subscription status: pending · active · cancelled · expired
Post type: text · poll · share · video
Report reason: spam · harassment · hate_speech · violence · nudity ·
misinformation · other
Report status: pending · reviewed · dismissed · actioned (capture-only — stays pending)
XP actions & values (per award)
| Action | XP | Daily cap | Dedupe |
|---|---|---|---|
post_created |
10 | 3 / day | — |
comment_created |
5 | 5 / day | — |
like_received |
2 | — | awarded to the post author |
lesson_completed |
20 | — | once per lesson |
course_completed |
100 | — | once per course |
session_booked |
30 | — | once per 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",
"pricing_type": "subscription",
"pricing_type_label": "Subscription",
"billing_interval": "monthly",
"billing_interval_label": "Monthly",
"price": "19.00",
"currency": "USD",
"member_count": 42,
"cover_url": null,
"icon_url": null,
"mentor": { "id": "8a1c…", "headline": "…", "average_rating": "4.90", "name": "Jane Doe", "avatar_url": null },
"level_thresholds": [ { "id": "…", "level": 2, "xp_required": 100, "label": "Contributor" } ],
"pricing_changed_at": null,
"published_at": "2026-07-06T09:00:00+00:00",
"created_at": "…",
"updated_at": "…"
}
mentor/level_thresholdsare included only when eager-loaded (show responses).
Member object
{
"id": "1a2b…", "community_id": "9b6f…", "user_id": "7d2e…",
"member_role": "member", "member_role_label": "Member",
"status": "active", "status_label": "Active",
"level": 1, "xp": 30,
"user": { "id": "7d2e…", "name": "John Doe", "avatar_url": null },
"joined_at": "2026-07-06T10:00:00+00:00"
}
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",
"reactions": [ { "type": "love", "count": 3 }, { "type": "like", "count": 1 } ],
"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,
"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.
shared_post (a nested post object) is present only on type: share posts; poll 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).
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
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 default to draft + free.
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 |
price |
decimal | conditionally | Required for one_time / subscription |
currency |
string | no | Default USD |
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": "draft", "status_label": "Draft",
"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": null,
"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 (regenerates slug), tagline, description, currency, cover, icon.
Pricing is changed via its own endpoint (below), not update.
Update payload — every field optional (sometimes); send only what changes.
| Field | Type | Notes |
|---|---|---|
name |
string | Max 255. Regenerates slug |
tagline |
string | Max 255 |
description |
string | |
currency |
string | |
cover |
file | Image ≤ 5 MB — multipart/form-data, replaces the existing cover |
icon |
file | Image ≤ 2 MB — replaces the existing icon |
Request
{
"name": "Product Leaders Circle",
"tagline": "Playbooks from senior PMs"
}
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": {} }.
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"
}
}
}
Only published communities appear in discovery and can be joined. published_at is stamped
on the first publish and kept thereafter, so an unpublish → republish cycle preserves it.
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 |
string | no |
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.
5. Members
GET /api/v1/mentor/communities/{community}/members → paginated (by XP desc)
DELETE /api/v1/mentor/communities/{community}/members/{member} → removes (status: removed)
The owner cannot be removed (422). Removal decrements member_count.
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 ≥ 2; level 1 is the implicit floor)
| Field | Type | Required | Notes |
|---|---|---|---|
levels |
array | yes | Bulk replace — omitted levels are deleted |
levels[].level |
int | yes | ≥ 2, unique within the payload |
levels[].xp_required |
int | yes | Must strictly increase with level |
levels[].label |
string | no | Display name for the tier |
Request
{
"levels": [
{ "level": 2, "xp_required": 100, "label": "Contributor" },
{ "level": 3, "xp_required": 250, "label": "Regular" }
]
}
Response 200
{
"status": "success",
"message": "Level thresholds updated successfully.",
"data": {
"level_thresholds": [
{ "id": "2f1a…", "level": 2, "xp_required": 100, "label": "Contributor" },
{ "id": "6c3d…", "level": 3, "xp_required": 250, "label": "Regular" }
]
}
}
XP must strictly increase with level, else 422. Saving recomputes every member's level.
7. Post moderation
DELETE /api/v1/mentor/communities/{community}/posts/{post} → remove any member's post
PATCH /api/v1/mentor/communities/{community}/posts/{post}/pin → { "data": { "post": { … } } }
PATCH /api/v1/mentor/communities/{community}/posts/{post}/unpin
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… ] } }
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 |
|
|---|---|---|
subscription |
created and active immediately, price_snapshot: null |
created pending |
membership |
granted now — the member object | null until payment |
checkout_url |
null |
signed checkout URL |
ends_at |
null (never lapses) |
subscription → renewal window · one_time → null |
No request body — pricing is read from the community, and the price is snapshotted server-side.
The response shape is identical either way, so one client branch handles both — check
checkout_url.
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": 1, "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, or you were banned (a ban is final —
a member who merely left may rejoin freely).
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).
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": 2,
"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
{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 |
Pinned posts always lead (is_pinned DESC), then the requested ordering: top ranks by the
engagement hot score (hot_score DESC, created_at DESC — same formula as the public feed,
see feed-api.md); recent is chronological (created_at DESC).
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",
"reactions": [ { "type": "love", "count": 3 }, { "type": "like", "count": 1 } ],
"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.
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.
Create payload
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | conditionally | Required unless poll or video_upload_id 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…
thumbnail=@frame.jpg
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 in the same community referencing the original
(shared_post) and increments the original's share_count. Sharing a share flattens to
the root post (no infinite nesting) — same behaviour as the public feed. Shares never
leave the community; there is no cross-community or public re-share.
Payload
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | no | Optional quote text, max 10000. Omit (or null) for a plain repost |
Request
{ "body": "Worth a read" }
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…" },
"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 now carries the same ×3 weight as the
public feed — see §12). No XP is awarded for sharing. Deleting the original later nulls
the reference (shared_post disappears from the share) rather than deleting the share.
Errors: 403 not an active member · 404 post not found in this community.
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.
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-reply preview)
GET /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/replies → paginated replies (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).
The comments listing returns only top-level comments, each with a 3-reply preview plus the
full replies_count. Reading a single post (§13) embeds the same shape. Fetch the rest of a
thread from the replies endpoint, which pages a single comment's replies oldest-first under
data.replies and 422s if the target is itself a reply. Both 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 top-level comment on this post — enforces one-level threading |
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,
"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 },
"created_at": "2026-07-20T10:10:00+00:00", "updated_at": "2026-07-20T10:10:00+00:00"
}
}
}
Awards comment_created XP (capped 5/day) and increments comment_count. Notifies the post
author unless they are the commenter. Delete returns 200 with
Comment deleted successfully. and an empty data.
Deleting a comment cascades its replies, and comment_count is decremented by the whole
removed subtree (the parent plus its replies).
Errors: 403 not an active member (list/create) or not the author (delete) · 422 missing
body, or a parent_id that does not exist, belongs to a different post, or is itself a reply.
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
Reports a post or comment 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.
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": {} }
Errors: 403 not an active member · 404 target not found in this community ·
409 already reported by you · 422 invalid reason.
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": { "audience_type": "community_member", "audience_label": "Community Member",
"is_free": true, "price": null, "currency": "USD" }
}
]
}
}
⚠️ 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.
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 }. type is one of
community.post_created · community.post_commented · community.pricing_change.
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.
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 only)
GET /api/v1/public/communities/{slug} → published community by slug (404 otherwise)
Query (browse): search (name/tagline), sort (popular default · newest · name), per_page (1–50).
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
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. - 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(floor 1).
Deferred / cross-phase (not in this cut)
| 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 |