MeetyyAPI
Documentation / API Reference / Communities

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

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_id stays 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 to video/mp4, video/quicktime, video/webm. Thumbnails are generated client-side, not on the server — the frontend extracts a frame and uploads it as the thumbnail field; the server stores it and echoes it back as video.thumbnail (null when 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_thresholds are included only when eager-loaded (show responses).

level_thresholds is member-only. It is present for an active member and for the owner, and absent for a guest or non-memberGET /communities/{id}/levels refuses them, so the public payload does not hand the same ladder out sideways. Guard the key.

language is drawn from a fixed vocabulary and tags[] from the admin-managed tag vocabulary; both are published by §18a, the vocabulary alone by §18b, and both are validated on write (§2).

tags[].icon is an icon key the admin set on the tag — null when 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_membertrue on an active membership, the unified access flag for free and paid alike. false for an authenticated non-member; omitted entirely for anonymous.
  • my_membership — the caller's own row (role, status, level, xp) or null. Reported at whatever status it holds, pending included — an applicant has to be told their request landed — while is_member stays 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. online means seen inside the presence window (15 min); admin counts 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_untilmuted_until is null unless 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 as true.
  • level0 is the floor every member stands on; a brand-new row is level: 0 and 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.mdHashtags.

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 postsreaction_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 an owner membership row, that single paginated list covers created and joined communities, told apart by is_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. Changing name now leaves it untouched; changing the handle is a separate, deliberate edit that sends slug.

Creating a community still derives its first slug from the name (including -1, -2 suffixing 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-data with _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_settings permission 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-onlylive · scheduled · expired · used_up · paused
  • PUT serves partial updates — the active/paused switch sends only {"is_active": false} and everything else survives.
  • status is 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-DD here, so nothing changes on the wire.

Redemption is built, but not wired to community joins. Coupons now live in a shared polymorphic coupons table with one validate-and-redeem path, which course coupons already use end to end. Applying one at join time is a coupon_code field on §9 join and nothing more; until that lands, a community code's redemptions_count stays 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 memberowner 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 or unlocks string.

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.

unlocks is 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 /mentor prefix these are not owner-only: the owner (through their mentor profile) or any member whose role manages the communityowner or admin. 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 …/pin and PATCH …/unpin also still answer, for clients built before POST/DELETE were introduced. Prefer POST/DELETE; the PATCH pair 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:

  • active membership rows only. A pending row is not a membership — the applicant is waiting on a decision or on their payment — and rejected, left, removed and banned are memberships that ended. None of them appear here.
  • Owned communities are included, through the owner row 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.status is not published; 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 with is_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_timenull

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". subscription and checkout_url are both null.
  • 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_urlPay 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 in CommunitySubscriptionService::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 adminmanage_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: pending fails 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 — not member_count, not total_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_questions comes back alongside so the queue can label the answers without a second call. join_answers is parallel to it by index.
  • join_answers is 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 required403 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 body is required only when the post would otherwise be empty. Posting a picture with no text is valid; posting nothing at all is a 422 on body.

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

Thumbnail. Generated on the client and sent as the thumbnail field in the same multipart/form-data request (same recipe as the public feed — see feed-api.md). The server never derives one, so video.thumbnail is null whenever 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. Only public feed posts may be carried in; a connections-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_count cannot drift away from the underlying votes. The response is always the poll as it stands after your write, with voted_by_me scoped to you.

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 commentscomment_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, and comment_count dropped by exactly 1.

is_deleted remains on the comment object and rows created under the old behaviour are still served, so keep rendering "This comment was deleted." when you see one — new deletes simply never produce one. 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}/like endpoints and the like_count / liked_by_me fields were removed. Use /reactions and read reaction_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 owner membership 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_id was removed — the endpoint is already scoped to one community. my_pricing is 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_pricing here is now the same block every other course payload carries — see my_pricing. It is always present (never null), it spells the key audience_type_label rather than audience_label, and it carries base_price, list_price, offer and savings on 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 authorityowner, admin, moderator, member.

  • is_system is true throughout: every role is built in, none was created by an owner. Custom roles are not supported.
  • is_assignable is false only for owner — 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 0 with 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 }
}
  • me is 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. null only when the caller has no membership row at all.
  • rank is null for a member who earned nothing in the range — unranked, not last.
  • points is XP earned within the range, not lifetime xp.
  • has_streak requires 3 consecutive active days.
  • total_ranked counts 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

status is reported against the clock, so a lapsed invite reads as expired without 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
    }
  }
}
  • PUT is 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 (digest is the only one defaulting to false). 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 · nobodyadmins means the owner, admins and moderators (canModerate(), the same predicate the moderation surfaces use).

  • PUT is 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 nobody does not put a member beyond the reach of the people who moderate them — otherwise a member could make themselves unanswerable to the owner. Read nobody as "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_level not 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:

  1. skips entirely if is_active is false,
  2. awards points — the owner's value, not the catalogue default,
  3. respects daily_limit for that member that day (UTC),
  4. skips if the member's level is below required_level,
  5. 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 public communities only. A private community is absent from browse, from the Discover page and from global search, and it does not count toward the price range in §18a — but GET /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:

  1. status = published
  2. visibility = public
  3. the caller holds no active membership — this covers communities they own, since the owner holds an owner row from creation
  4. the caller holds no pending row — 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_thresholds is 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_membertrue when 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. false for 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_member for "does this user have access". To single out a paying subscriber, check subscription.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.options is the whole vocabulary, whether or not any community carries a given tag. It is admin-managed at runtime — see admin-community-tag-api.md — so treat it as data, never as a hardcoded list. Read icon per option rather than mapping slugs client-side: a slug an admin added yesterday has no entry in a map shipped last release.
  • languages.options is the full allowed set, served from config('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 validates language on §2, so a value outside it is a 422. Extending the list is a one-line config change.
  • price.min / price.max span 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.options was 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 is specific_communitycommunity_memberpublic (ties broken by the cheapest option). Non-members, anonymous callers, and contexts with no community resolve to the public price.
  • Visibilitycommunity_only courses 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_level locks 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 flagged is_locked: true and their lessons are withheld. unlock_at_level is rejected on a course no community carries. unlock_at_level: 0 gates 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.xp is a denormalized running total.
  • Daily-cap buckets are keyed on the UTC award date.
  • A member's level is the highest threshold whose xp_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.
  • level is 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:

  • nobody on 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).
  • language is 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_code on 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