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.

  • 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 (requires an approved mentor profile; ownership enforced by scoping → 404 otherwise). Participation (join, feed, courses) lives under the mentee surface because every user has a mentee profile; membership is keyed by user_id, so a mentor participates in another mentor's community through the same mentee endpoints. The gated course catalog is the one exception — it is mirrored on the mentor surface as a convenience (§7a).

Scope note. Real payment settlement is deferred (no gateway yet): paid subscriptions settle through a fake checkout page mirroring the booking flow — a signed link with Pay / Cancel. payment_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 500 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) return field errors in data.

Status codes

Code Meaning
200 OK (read, update, lifecycle action)
201 Resource created (community, post, comment, share, report, join — free or checkout)
401 Missing/invalid token
403 Authenticated but not an active member (feed, post, comment, like, share, report, me, courses)
404 Not found, or not owned by the caller (mentor scoping)
409 Content already reported by this user
422 Validation error / invalid state (e.g. join a community you already belong to, non-increasing levels)
500 Server error

Enums

Community status: draft · published · archived

Pricing type: free · one_time · subscription Billing interval: monthly · yearly (required only for subscription; null otherwise)

Member role: owner · member Member status: active · left · removed · banned (only active may participate) left is a self-exit (§9a) and may rejoin; removed is owner moderation; banned is final — a banned user is refused by join.

Subscription status: pending · active · cancelled · expired

Post type: text · poll · share · video

Report reason: spam · harassment · hate_speech · violence · nudity · misinformation · other Report status: pending · reviewed · dismissed · actioned (capture-only — stays pending)

XP actions & values (per award)

Action XP Daily cap Dedupe
post_created 10 3 / day
comment_created 5 5 / day
like_received 2 awarded to the post author
lesson_completed 20 once per lesson
course_completed 100 once per course
session_booked 30 once per community

Shared objects

Community object

{
  "id": "9b6f…",
  "mentor_id": "8a1c…",
  "name": "Product Leaders Circle",
  "slug": "product-leaders-circle",
  "tagline": "Level up your PM career",
  "description": "…",
  "status": "published",
  "status_label": "Published",
  "pricing_type": "subscription",
  "pricing_type_label": "Subscription",
  "billing_interval": "monthly",
  "billing_interval_label": "Monthly",
  "price": "19.00",
  "currency": "USD",
  "member_count": 42,
  "cover_url": null,
  "icon_url": null,
  "mentor": { "id": "8a1c…", "headline": "…", "average_rating": "4.90", "name": "Jane Doe", "avatar_url": null },
  "level_thresholds": [ { "id": "…", "level": 2, "xp_required": 100, "label": "Contributor" } ],
  "pricing_changed_at": null,
  "published_at": "2026-07-06T09:00:00+00:00",
  "created_at": "…",
  "updated_at": "…"
}

mentor / level_thresholds are included only when eager-loaded (show responses).

Member object

{
  "id": "1a2b…", "community_id": "9b6f…", "user_id": "7d2e…",
  "member_role": "member", "member_role_label": "Member",
  "status": "active", "status_label": "Active",
  "level": 1, "xp": 30,
  "user": { "id": "7d2e…", "name": "John Doe", "avatar_url": null },
  "joined_at": "2026-07-06T10:00:00+00:00"
}

Post object

Mirrors the public-feed post shape (minus visibility/connection). type is text, poll, share, or video; a share post carries the original under shared_post, a poll post carries the poll under poll.

{
  "id": "3c4d…",
  "type": "text", "type_label": "Text",
  "body": "Excited to be here!",
  "reaction_count": 4, "comment_count": 2, "share_count": 1,
  "my_reaction": "love",
  "reactions": [ { "type": "love", "count": 3 }, { "type": "like", "count": 1 } ],
  "image_urls": [],
  "video": { "url": "https://…/clip.mp4", "thumbnail": "https://…/clip-thumb.jpg", "mime": "video/mp4" },
  "author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": false },
  "shared_post": null,
  "comments": [ { "…comment object…" } ],
  "community_id": "9b6f…", "is_pinned": false, "pinned_at": null,
  "created_at": "…", "updated_at": "…"
}

my_reaction is the caller's own reaction type (or null). reactions is the per-type breakdown (count-desc), present on every response alongside the reaction_count total. shared_post (a nested post object) is present only on type: share posts; poll only on type: poll posts; video only on posts that carry a video. On author, role is the author's platform role ("mentor", "mentee", "admin", or null when they have none).

Poll object (post.poll)

Same shape as the public feed (see feed-api.md):

{
  "id": "9a0b…",
  "question": "Best day for the weekly call?",
  "allows_multiple": false,
  "expires_at": null,
  "is_closed": false,
  "total_votes": 12,
  "options": [
    { "id": "…", "label": "Monday", "position": 0, "vote_count": 7, "voted_by_me": true },
    { "id": "…", "label": "Friday", "position": 1, "vote_count": 5, "voted_by_me": false }
  ]
}

is_closed is true once expires_at has passed. voted_by_me reflects the caller's vote(s).

Comment object

{
  "id": "7f8a…",
  "post_id": "3c4d…",
  "user_id": "7d2e…",
  "parent_id": null,
  "body": "Welcome aboard!",
  "reaction_count": 2,
  "my_reaction": "like",
  "my_reaction_exists": true,
  "reactions": [ { "type": "like", "count": 2 } ],
  "author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "is_me": false },
  "replies": [ { "…comment object…" } ],
  "replies_count": 12,
  "created_at": "…",
  "updated_at": "…"
}

Comments carry the same reaction shape as 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

All require an approved mentor profile. Another mentor's community (or a missing profile) → 404.

1. List / create communities

GET  /api/v1/mentor/communities                → { "data": { "communities": [ … ] } }
POST /api/v1/mentor/communities                (multipart if sending cover/icon)  → 201

Creating a community seeds the owner as the first member (role owner, member_count = 1) and auto-generates a unique slug from the name. New communities default to draft + free.

Self-only. This lists the caller's own communities. To read another user's communities, use GET /api/v1/user/{username}/communities (Authentication API, endpoint 43). Because the owner holds 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
price decimal conditionally Required for one_time / subscription
currency string no Default USD
cover file no Image ≤ 5 MB
icon file no Image ≤ 2 MB

Request (JSON; use multipart/form-data with the same field names when sending cover/icon)

{
  "name": "Product Leaders Circle",
  "tagline": "Level up your PM career",
  "description": "A space for senior PMs to trade playbooks.",
  "pricing_type": "subscription",
  "billing_interval": "monthly",
  "price": "19.00",
  "currency": "USD"
}

Response 201

{
  "status": "success",
  "message": "Community created successfully.",
  "data": {
    "community": {
      "id": "9b6f…", "mentor_id": "8a1c…",
      "name": "Product Leaders Circle", "slug": "product-leaders-circle",
      "tagline": "Level up your PM career", "description": "A space for senior PMs to trade playbooks.",
      "status": "draft", "status_label": "Draft",
      "pricing_type": "subscription", "pricing_type_label": "Subscription",
      "billing_interval": "monthly", "billing_interval_label": "Monthly",
      "price": "19.00", "currency": "USD",
      "member_count": 1,
      "cover_url": null, "icon_url": null,
      "pricing_changed_at": null, "published_at": null,
      "created_at": "2026-07-06T09:00:00+00:00", "updated_at": "2026-07-06T09:00:00+00:00"
    }
  }
}

Errors: 422 missing name, subscription without billing_interval, or paid without price · 404 no approved mentor profile.

2. Show / update / delete

GET       /api/v1/mentor/communities/{slug}   → { "data": { "community": { …incl. level_thresholds… } } }
PUT|PATCH /api/v1/mentor/communities/{id}     (multipart to replace cover/icon)
DELETE    /api/v1/mentor/communities/{id}     → cascades members, posts, subscriptions, thresholds

Show resolves by slug; update and delete resolve by {id}.

Update accepts name (regenerates slug), tagline, description, currency, cover, icon. Pricing is changed via its own endpoint (below), not update.

Update payload — every field optional (sometimes); send only what changes.

Field Type Notes
name string Max 255. Regenerates slug
tagline string Max 255
description string
currency string
cover file Image ≤ 5 MB — multipart/form-data, replaces the existing cover
icon file Image ≤ 2 MB — replaces the existing icon

Request

{
  "name": "Product Leaders Circle",
  "tagline": "Playbooks from senior PMs"
}

Response 200

{
  "status": "success",
  "message": "Community updated successfully.",
  "data": {
    "community": { "…community object, with the new name/slug/tagline…" }
  }
}

Delete responds { "status": "success", "message": "Community deleted successfully.", "data": {} }.

3. Lifecycle: publish / unpublish / archive

PATCH /api/v1/mentor/communities/{id}/publish     → status: published (sets published_at once)
PATCH /api/v1/mentor/communities/{id}/unpublish   → status: draft
PATCH /api/v1/mentor/communities/{id}/archive     → status: archived

No request body. Each returns 200 with the updated community and its own message — Community published successfully. · Community unpublished successfully. · Community archived successfully.

{
  "status": "success",
  "message": "Community published successfully.",
  "data": {
    "community": {
      "…community object…",
      "status": "published", "status_label": "Published",
      "published_at": "2026-07-06T09:30:00+00:00"
    }
  }
}

Only published communities appear in discovery and can be joined. published_at is stamped on the first publish and kept thereafter, so an unpublish → republish cycle preserves it.

4. Change pricing

PATCH /api/v1/mentor/communities/{id}/pricing
Field Type Required Notes
pricing_type enum yes free · one_time · subscription
billing_interval enum conditionally Required if subscription
price decimal conditionally Required unless free
currency string no

Request

{
  "pricing_type": "subscription",
  "billing_interval": "yearly",
  "price": "180.00",
  "currency": "USD"
}

Response 200

{
  "status": "success",
  "message": "Community pricing updated successfully.",
  "data": {
    "community": {
      "…community object…",
      "pricing_type": "subscription", "pricing_type_label": "Subscription",
      "billing_interval": "yearly", "billing_interval_label": "Yearly",
      "price": "180.00", "currency": "USD",
      "pricing_changed_at": "2026-07-20T11:00:00+00:00"
    }
  }
}

Sets pricing_changed_at. On a free → paid transition (or a price rise), active members receive a community.pricing_change notification with a 30-day effective_at. Existing subscriptions keep their original price_snapshot (grandfathered).

Errors: 422 subscription without billing_interval, or paid without price.

5. Members

GET    /api/v1/mentor/communities/{community}/members            → paginated (by XP desc)
DELETE /api/v1/mentor/communities/{community}/members/{member}   → removes (status: removed)

The owner cannot be removed (422). Removal decrements member_count.

6. Level thresholds (gamification config)

GET /api/v1/mentor/communities/{community}/level-thresholds   → { "data": { "level_thresholds": [ … ] } }
PUT /api/v1/mentor/communities/{community}/level-thresholds   → bulk replace + recompute member levels

Payload (levels ≥ 2; level 1 is the implicit floor)

Field Type Required Notes
levels array yes Bulk replace — omitted levels are deleted
levels[].level int yes ≥ 2, unique within the payload
levels[].xp_required int yes Must strictly increase with level
levels[].label string no Display name for the tier

Request

{
  "levels": [
    { "level": 2, "xp_required": 100, "label": "Contributor" },
    { "level": 3, "xp_required": 250, "label": "Regular" }
  ]
}

Response 200

{
  "status": "success",
  "message": "Level thresholds updated successfully.",
  "data": {
    "level_thresholds": [
      { "id": "2f1a…", "level": 2, "xp_required": 100, "label": "Contributor" },
      { "id": "6c3d…", "level": 3, "xp_required": 250, "label": "Regular" }
    ]
  }
}

XP must strictly increase with level, else 422. Saving recomputes every member's level.

7. Post moderation

DELETE /api/v1/mentor/communities/{community}/posts/{post}         → remove any member's post
PATCH  /api/v1/mentor/communities/{community}/posts/{post}/pin     → { "data": { "post": { … } } }
PATCH  /api/v1/mentor/communities/{community}/posts/{post}/unpin

No request body on pin/unpin. Each returns 200 with the updated post — Post pinned successfully. / Post unpinned successfully.

{
  "status": "success",
  "message": "Post pinned successfully.",
  "data": {
    "post": {
      "…post object…",
      "is_pinned": true,
      "pinned_at": "2026-07-20T12:00:00+00:00"
    }
  }
}

Unpinning sets is_pinned: false and pinned_at: null. Delete responds { "status": "success", "message": "Post removed successfully.", "data": {} }.

Pinned posts sort to the top of the feed.

7a. Gated course catalog (mirror)

GET /api/v1/mentor/communities/{community}/courses

The same endpoint as §16 — see there for the payload, gating and pricing rules. Gated on active membership, not ownership.


Mentee endpoints — participation

All require a Sanctum token. Feed / posts / comments / likes / me / courses require active membership of the community, else 403.

8. My memberships

GET /api/v1/mentee/memberships     → { "data": { "memberships": [ …member object w/ community… ] } }

Self-only, and membership-shaped. This returns membership records (level, XP, standing) for the caller. To list the communities another user belongs to, use GET /api/v1/user/{username}/communities (Authentication API, endpoint 43) — it returns community summaries flagged 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
subscription created and active immediately, price_snapshot: null created pending
membership granted now — the member object null until payment
checkout_url null signed checkout URL
ends_at null (never lapses) subscription → renewal window · one_timenull

No request body — pricing is read from the community, and the price is snapshotted server-side.

The response shape is identical either way, so one client branch handles both — check checkout_url.

Response — free community 201

{
  "status": "success",
  "message": "You have joined the community.",
  "data": {
    "membership": {
      "id": "1a2b…", "community_id": "9b6f…", "user_id": "7d2e…",
      "member_role": "member", "member_role_label": "Member",
      "status": "active", "status_label": "Active",
      "level": 1, "xp": 0,
      "joined_at": "2026-07-20T10:00:00+00:00"
    },
    "subscription": {
      "id": "5e6f…", "community_id": "9b6f…", "user_id": "7d2e…",
      "pricing_type": "free", "pricing_type_label": "Free",
      "billing_interval": null, "billing_interval_label": null,
      "price_snapshot": null, "currency": "USD",
      "status": "active", "status_label": "Active",
      "starts_at": "2026-07-20T10:00:00+00:00", "ends_at": null, "cancelled_at": null,
      "created_at": "2026-07-20T10:00:00+00:00"
    },
    "checkout_url": null
  }
}

Response — paid community 201

{
  "status": "success",
  "message": "Checkout started. Complete payment to activate your membership.",
  "data": {
    "membership": null,
    "subscription": {
      "id": "5e6f…", "community_id": "9b6f…", "user_id": "7d2e…",
      "pricing_type": "subscription", "pricing_type_label": "Subscription",
      "billing_interval": "monthly", "billing_interval_label": "Monthly",
      "price_snapshot": "19.00", "currency": "USD",
      "status": "pending", "status_label": "Pending",
      "starts_at": null, "ends_at": null, "cancelled_at": null,
      "created_at": "2026-07-20T10:00:00+00:00"
    },
    "checkout_url": "https://api.example.com/checkout/community/5e6f…?signature=…"
  }
}

checkout_url is a temporary signed URL valid for 24 hours; re-calling join mints a fresh one.

For a paid community, open checkout_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, or you were banned (a ban is final — a member who merely left may rejoin freely).

9a. Leave a community (free or paid)

DELETE /api/v1/mentee/communities/{community}/leave     → 200

The mirror of join, and the only exit. Membership status becomes left and any live subscription (active or pending) is cancelled in the same transaction, so a departed member is never left holding access. member_count decrements.

{
  "data": {
    "membership": { "…member object, status: \"left\"…" },
    "subscription": { "…subscription object, status: \"cancelled\"…" }
  }
}

subscription is null when there was nothing live to cancel. Rejoining later is a plain call to §9 — it reactivates the existing row and issues a fresh subscription.

Cancellation is immediate for both pricing models. A paid member forfeits the remainder of a paid period; there is no grace window until ends_at. This is deliberate while settlement is faked — when a real gateway lands, the grace window belongs 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).

10. Subscription state

GET /api/v1/mentee/communities/{community}/subscription     → current subscription (404 if none)

Read-only. Subscriptions are created by join (§9) and cancelled by leave (§9a).

11. My standing

GET /api/v1/mentee/communities/{community}/me
{
  "data": {
    "membership": { "…member object…" },
    "next_level": 2,
    "next_level_xp_required": 100,
    "xp_to_next_level": 70
  }
}

next_level is null at the top tier.

12. Feed

GET /api/v1/mentee/communities/{community}/feed
GET /api/v1/mentee/communities/{community}/feed?sort=recent&per_page=30

{community} is the community id (not the slug — slug resolution is mentor-show only, §2).

Query params

Param Type Default Notes
sort enum top top (engagement-ranked) · recent (newest first). Invalid value → 422
per_page int 15 1–50. Out of range → 422

Pinned posts always lead (is_pinned DESC), then the requested ordering: top ranks by the engagement hot score (hot_score DESC, created_at DESC — same formula as the public feed, see feed-api.md); recent is chronological (created_at DESC).

Response — a Laravel resource collection under posts, with the standard data / links / meta structure:

{
  "status": "success",
  "message": "Community feed retrieved successfully.",
  "data": {
    "posts": {
      "data": [
        {
          "id": "3c4d…",
          "type": "text", "type_label": "Text",
          "body": "Excited to be here!",
          "reaction_count": 4, "comment_count": 2, "share_count": 0,
          "my_reaction": "love",
          "reactions": [ { "type": "love", "count": 3 }, { "type": "like", "count": 1 } ],
          "image_urls": [],
          "video": { "url": "https://…/clip.mp4", "thumbnail": "https://…/clip-thumb.jpg", "mime": "video/mp4" },
          "author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": false },
          "community_id": "9b6f…", "is_pinned": true, "pinned_at": "2026-07-06T12:00:00+00:00",
          "created_at": "2026-07-06T10:00:00+00:00", "updated_at": "2026-07-06T10:00:00+00:00"
        }
      ],
      "links": { "first": "…?page=1", "last": "…?page=4", "prev": null, "next": "…?page=2" },
      "meta": { "current_page": 1, "from": 1, "to": 15, "per_page": 15, "last_page": 4, "total": 52, "path": "…", "links": [ … ] }
    }
  }
}

Each post carries the caller's my_reaction (or null) and the per-type reactions breakdown. Poll posts carry their poll (with the caller's voted_by_me flags) in both the feed and single-post reads. The video key is omitted on posts without a video, and comments is omitted here — the feed does not eager-load them. Read a single post (§13) for its comment thread, or page through them via the comments listing (§14).

Errors: 403 not an active member · 404 community not found · 422 invalid sort / per_page.

13. Posts

POST /api/v1/mentee/communities/{community}/posts                  (multipart for attachments) → 201
GET  /api/v1/mentee/communities/{community}/posts/{post}
PUT  /api/v1/mentee/communities/{community}/posts/{post}           (author only, else 403)
DELETE /api/v1/mentee/communities/{community}/posts/{post}         (author only; owner uses moderation)

A post is text, a poll when a poll object is present, or a video when a video_upload_id is present — same rules as the public feed.

Create payload

Field Type Required Notes
body string conditionally Required unless poll or video_upload_id is present. Max 10000
attachments[] file[] no ≤ 10 files, ≤ 10 MB each. Mutually exclusive with video_upload_id
video_upload_id uuid no A completed community_video chunked upload. Mutually exclusive with attachments and poll
thumbnail file no Client-generated video frame, ≤ 5 MB. Only meaningful with video_upload_id
poll object no Presence makes the post a poll
poll.question string no ≤ 255
poll.allows_multiple bool no Default false
poll.expires_at date no Must be in the future
poll.options array with poll 2–10 entries
poll.options.* string yes ≤ 255

Request — text-only post (application/json)

{ "body": "Excited to be here!" }

Request — poll post (application/json)

{
  "body": "Vote below!",
  "poll": {
    "question": "Best day for the weekly call?",
    "allows_multiple": false,
    "expires_at": "2026-08-01T00:00:00Z",
    "options": ["Monday", "Friday"]
  }
}

Request — video post (multipart/form-data)

body=Here is my walkthrough
video_upload_id=1f4c…
thumbnail=@frame.jpg

Response 201

{
  "status": "success",
  "message": "Post created successfully.",
  "data": {
    "post": {
      "id": "3c4d…",
      "type": "text", "type_label": "Text",
      "body": "Excited to be here!",
      "reaction_count": 0, "comment_count": 0, "share_count": 0,
      "my_reaction": null, "reactions": [],
      "image_urls": [],
      "author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": true },
      "community_id": "9b6f…", "is_pinned": false, "pinned_at": null,
      "created_at": "2026-07-20T10:05:00+00:00", "updated_at": "2026-07-20T10:05:00+00:00"
    }
  }
}

Update payload: body only (same 10000 cap) — attachments and video cannot be swapped after creation. Returns 200 with message Post updated successfully. and the same post shape; delete returns Post deleted successfully. with an empty data.

{ "body": "Excited to be here! (edited)" }

Creating a post awards post_created XP (capped 3/day). When the owner posts, all other active members get a community.post_created notification.

Errors: 403 not an active member (create) or not the author (update/delete) · 422 missing body without a poll or video, invalid poll rules, or attachments/poll sent alongside video_upload_id.

Video posts. Upload the file first via the Chunked Upload API (purpose community_video), then create the post with the 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 in the same community referencing the original (shared_post) and increments the original's share_count. Sharing a share flattens to the root post (no infinite nesting) — same behaviour as the public feed. Shares never leave the community; there is no cross-community or public re-share.

Payload

Field Type Required Notes
body string no Optional quote text, max 10000. Omit (or null) for a plain repost

Request

{ "body": "Worth a read" }

Response 201

{
  "status": "success",
  "message": "Post shared successfully.",
  "data": {
    "post": {
      "id": "8e9f…",
      "type": "share", "type_label": "Share",
      "body": "Worth a read",
      "reaction_count": 0, "comment_count": 0, "share_count": 0,
      "my_reaction": null, "reactions": [],
      "image_urls": [],
      "author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "role": "mentee", "is_me": true },
      "shared_post": { "…the original post object, share_count: 1…" },
      "community_id": "9b6f…", "is_pinned": false, "pinned_at": null,
      "created_at": "2026-07-22T10:00:00+00:00", "updated_at": "2026-07-22T10:00:00+00:00"
    }
  }
}

Sharing feeds the original's hot score (share_count now carries the same ×3 weight as the public feed — see §12). No XP is awarded for sharing. Deleting the original later nulls the reference (shared_post disappears from the share) rather than deleting the share.

Errors: 403 not an active member · 404 post not found in this community.

13b. Polls

POST   /api/v1/mentee/communities/{community}/posts/{post}/poll/vote     body: { "option_ids": ["…"] }  → { "data": { "poll": { … } } }
DELETE /api/v1/mentee/communities/{community}/posts/{post}/poll/vote     → retracts the caller's vote(s)

Voting replaces any previous vote by the caller. Same rules as the public feed:

  • Single-choice polls (allows_multiple: false) reject more than one option → 422.
  • Multi-choice polls accept multiple option_ids.
  • Options must belong to the poll → 422 otherwise.
  • Voting on a closed poll (past expires_at) → 422.

Per-option vote_count and the caller's voted_by_me flags are recomputed and returned.

Errors: 403 not an active member · 404 post not found in this community, or the post has no poll · 422 poll rules above.

14. Comments

GET    /api/v1/mentee/communities/{community}/posts/{post}/comments                        → paginated top-level comments (newest first, 3-reply preview)
GET    /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/replies      → paginated replies (oldest first)
POST   /api/v1/mentee/communities/{community}/posts/{post}/comments                        → 201
DELETE /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}              (author only, else 403)

List query params (both GETs): per_page (optional, 1–50, default 15).

The comments listing returns only top-level comments, each with a 3-reply preview plus the full replies_count. Reading a single post (§13) embeds the same shape. Fetch the rest of a thread from the replies endpoint, which pages a single comment's replies oldest-first under data.replies and 422s if the target is itself a reply. Both require active membership.

Response 200

{
  "status": "success",
  "message": "Comments retrieved successfully.",
  "data": {
    "comments": {
      "data": [ { "…comment object…" } ],
      "links": { "…" },
      "meta": { "current_page": 1, "per_page": 15, "total": 2, "…": "…" }
    }
  }
}

Create payload

Field Type Required Notes
body string yes Max 5000
parent_id uuid no A top-level comment on this post — enforces one-level threading

Request

{ "body": "Welcome aboard!", "parent_id": null }

Response 201

{
  "status": "success",
  "message": "Comment added successfully.",
  "data": {
    "comment": {
      "id": "7f8a…", "post_id": "3c4d…", "user_id": "7d2e…", "parent_id": null,
      "body": "Welcome aboard!",
      "reaction_count": 0, "my_reaction": null, "my_reaction_exists": false, "reactions": [],
      "author": { "id": "7d2e…", "username": "johndoe", "name": "John Doe", "avatar_url": null, "is_me": true },
      "created_at": "2026-07-20T10:10:00+00:00", "updated_at": "2026-07-20T10:10:00+00:00"
    }
  }
}

Awards comment_created XP (capped 5/day) and increments comment_count. Notifies the post author unless they are the commenter. Delete returns 200 with Comment deleted successfully. and an empty data.

Deleting a comment cascades its replies, and comment_count is decremented by the whole removed subtree (the parent plus its replies).

Errors: 403 not an active member (list/create) or not the author (delete) · 422 missing body, or a parent_id that does not exist, belongs to a different post, or is itself a reply.

15. Reactions

Typed reactions (like · love · laugh · wow · sad · angry) on posts and comments, reusing the public-feed reaction system. One reaction per user per target; posting again with a different type changes it (no double-count).

POST   /api/v1/mentee/communities/{community}/posts/{post}/reactions                       { "type": "love" } → { "data": { "reaction_count": n } }
DELETE /api/v1/mentee/communities/{community}/posts/{post}/reactions                        → { "data": { "reaction_count": n } }
POST   /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/reactions     { "type": "love" } → { "data": { "reaction_count": n } }
DELETE /api/v1/mentee/communities/{community}/posts/{post}/comments/{comment}/reactions     → { "data": { "reaction_count": n } }

Payload (POST only; DELETE takes no body)

Field Type Required Notes
type enum yes like · love · laugh · wow · sad · angry. Invalid → 422

Request

{ "type": "love" }

Response 200 — the target's new total only; re-read the post for the reactions breakdown.

{
  "status": "success",
  "message": "Reaction saved successfully.",
  "data": { "reaction_count": 5 }
}

DELETE returns the same shape with message Reaction removed successfully. Removing a reaction you never made is a no-op, not an error — the count comes back unchanged.

The first reaction on a post awards reaction_received XP to the post author (not the reactor); changing type or removing a reaction does not deduct XP, and comment reactions award no XP. 403 if not an active member.

Breaking change. The former POST/DELETE …/posts/{post}/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

Reports a post or comment to the shared moderation pipeline (the same polymorphic feed_reports capture the public feed uses — capture-only in this cut; no moderation surface yet). One report per user per target — a duplicate returns 409.

Payload

Field Type Required Notes
reason enum yes spam · harassment · hate_speech · violence · nudity · misinformation · other
details string no Free text, max 2000

Request

{ "reason": "harassment", "details": "Targets another member repeatedly." }

Response 201

{ "status": "success", "message": "Report submitted successfully.", "data": {} }

Errors: 403 not an active member · 404 target not found in this community · 409 already reported by you · 422 invalid reason.

16. Gated course catalog

GET /api/v1/mentee/communities/{community}/courses
GET /api/v1/mentor/communities/{community}/courses     ← identical response

Available on both surfaces — same controller, same gating, same payload. Use whichever matches the caller's context; a mentor browsing a community they own or belong to can stay on the mentor surface instead of crossing to /mentee.

The mentor route is gated on active membership, not ownership. Owners pass because creating a community writes an 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": { "audience_type": "community_member", "audience_label": "Community Member",
                        "is_free": true, "price": null, "currency": "USD" }
      }
    ]
  }
}

⚠️ 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.

See Course gating below for how pricing and module locking resolve. 403 if not a member.

17. Notifications (in-app)

GET   /api/v1/notifications                 → paginated + "unread_count"
PATCH /api/v1/notifications/{id}/read       → mark one read
POST  /api/v1/notifications/read-all        → mark all read

Each entry: { id, type, data, read_at, created_at }. type is one of community.post_created · community.post_commented · community.pricing_change.

No request body on either mutating call. Both return 200 with an empty data:

{ "status": "success", "message": "Notification marked as read.", "data": {} }
{ "status": "success", "message": "All notifications marked as read.", "data": {} }

PATCH …/{id}/read on an unknown or someone else's notification → 404.


Public endpoints — discovery

No authentication required. A Sanctum token is optional — when supplied, the show response is personalised for the caller (see is_member / subscription below).

18. Browse / show

GET /api/v1/public/communities            → paginated (published only)
GET /api/v1/public/communities/{slug}     → published community by slug (404 otherwise)

Query (browse): search (name/tagline), sort (popular default · newest · name), per_page (1–50).

Optional auth on show: if a valid Authorization: Bearer {token} is sent, the community object gains two booleans scoped to the caller:

  • is_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


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.
  • 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 (floor 1).

Deferred / cross-phase (not in this cut)

Item Present Unblocks with
Events calendar + "attend event" XP (+50) Future community cycle
Report moderation surface (list/resolve reports, hide/remove content) reports captured in feed_reports (§15a) Future moderation cycle
Real subscription billing / renewals / dunning community_subscriptions.payment_id (unconstrained) Payments module
Queued notification fan-out database notifications (synchronous) When a queue worker is provisioned
Email / push notification channels database channel only Phase 10 — Notifications