MeetyyAPI
Documentation / API Reference / Courses

Course API

Endpoints for the course system (Phase 6). Covers a mentor authoring a course (modules → lessons), publishing and pricing it; the public discovering and previewing published courses; a mentee enrolling and learning with progress tracking; reviews and the storefront; and the management rail — students, coursework, notices, materials, notes and the course discussion.

  • Base URL: /api/v1
  • Auth: mentor and mentee endpoints require a Sanctum bearer token — Authorization: Bearer {token}. Public discovery endpoints need no auth.
  • Content type: application/json (file uploads use multipart/form-data)

Scope note. Payment settlement is live: a paid enrollment is recorded as pending_payment, Enroll returns a checkout_url, and access begins only when the payment settles (CourseEnrollmentSettler). Community linkage is live too — community-only serving, level-gated modules (unlock_at_level), and community/specific-community pricing all resolve, see Community linkage. Still deferred: premium-exclusive courses (is_exclusive_to_premium, its column exists but is not yet enforced) and enrollment expiry (the expired status exists so clients can filter on it, but nothing sets it).


⚠️ Breaking changes — the course-system contract

Delivered against the client's three-phase course contract. Everything below is live; this document is the reference for what exists today.

Endpoint Change
GET /mentor/courses Now paginated. Was a bare array — read data.courses.data, with data.courses.meta carrying current_page / last_page / total
GET /public/courses/{slug}, GET /public/courses is_enrolled / is_wishlisted were omitted for guests; all four viewer flags are now always present, false for a guest. Any client keying off their presence rather than their value needs updating
Every course payload Added awaits_payment — the abandoned-checkout state, so the resume-payment button is reachable from the course resource alone
POST /mentee/courses/{course}/enroll A paid enrollment is now pending_payment and returns checkout_url; access begins when payment settles rather than immediately
GET /public/courses Unsupported filter values are now a 422 rather than silently ignored
GET /mentor/courses/{course} (and every sub-resource) {course} now resolves a slug or an id

New surfaces: course filters, reviews, authors, and the six management rail screens.


⚠️ Breaking changes — multi-community courses

A course is no longer limited to one community. courses.community_id has been dropped in favour of a community_course pivot, so a course can be carried by any number of communities and priced independently in each. Full mechanics in Community linkage.

Response changes

Endpoint Change
POST/GET/PUT /mentor/courses… Removed data.course.community_id. Added data.course.community_ids (array of uuids, [] when unlinked)
GET /mentee/communities/{community}/courses Removed data.courses[].community_id — the endpoint is already scoped to one community, and my_pricing is now resolved in that community's context
POST /mentee/courses/{course}/enroll Added data.enrollment.community_id — the community the access came through (null for a public enrollment)
  {
    "id": "9d1c…",
    "mentor_id": "8b2a…",
-   "community_id": "7f3e…",
+   "community_ids": ["7f3e…", "5a9b…"],
    "title": "From Junior to Senior Engineer",
    "visibility": "community_only"
  }

Request changes

Endpoint Change
POST /mentor/courses, PUT /mentor/courses/{course} Added community_ids (array of uuids, each a community you own). community_id is still accepted as a deprecated alias and folded into the set
POST /mentee/courses/{course}/enroll Added optional community_id — enroll through a specific community. Omit it and the carrying community where you hold the highest level is chosen
POST/PUT …/modules unlock_at_level is now rejected unless a community carries the course (422)
// Create a course carried by two communities
{
  "title": "From Junior to Senior Engineer",
  "visibility": "community_only",
  "community_ids": ["7f3e…", "5a9b…"]
}

Update semantics: omit community_ids to leave the links untouched; send an explicit array to replace the whole set. "community_ids": [] (or the deprecated "community_id": null) unlinks the course — and is rejected with 422 if its visibility is community_only.

Migration for clients: replace reads of community_id with community_ids[0] where a single value is still assumed, and switch writes to community_ids. Validation errors are reported against whichever key you sent — community_id or community_ids.{index}.


Response envelope

Every response uses the standard envelope.

Success

{ "status": "success", "message": "…", "data": { } }

Error

{ "status": "error", "message": "…", "data": { } }

Validation errors (422) return field errors in data:

{
  "status": "error",
  "message": "The title field is required.",
  "data": { "title": ["The title field is required."] }
}

Status codes

Code Meaning
200 OK (read, update, lifecycle action)
201 Resource created
401 Missing/invalid token
403 Authenticated but not entitled (e.g. content access without enrollment)
404 Not found or not owned by the caller
422 Validation error / invalid state (e.g. enroll in a draft, quiz on a non-quiz lesson, quiz time limit exceeded, late assignment without a note)
500 Server error

Enums

Course status: draft · published · archived

Course visibility

Value Public search Direct slug Notes
public Standard public course
unlisted Share by link only
community_only Members-only — gated to the carrying communities (see Community linkage)

Course level: beginner · intermediate · advanced · all (defaults to all, which renders as "All levels" — never null)

Course category (slug): development · business · design · marketing · finance · it-and-software · personal-development · photography · music · health-and-fitness · teaching · lifestyle · other

Course language (code): english · spanish · french · german · portuguese · italian · arabic · hindi · bengali · urdu · chinese · japanese · korean · russian · turkish · indonesian · other

Category, language and level are fixed vocabularies, served by GET /public/course-filters. A value outside them is a 422, not a silent ignore — an author can never save a category the server does not recognise.

Lesson type: video · text · pdf · quiz · assignment (quiz auto-scores; assignment is a mentor-graded file submission)

Course author role: owner · co_author · assistant (owner is derived from courses.mentor_id and can never be assigned — see §32)

Course material type: video · image · file · link

Submission type: assignment · quiz (not a stored column — both are course_lesson_progress rows, and the type is the parent lesson's lesson_type)

Pricing audience: public · community_member · specific_community (all three resolve — within a community's context, a member's community price wins over the public price; see Community linkage)

Enrollment status: pending_payment · active · completed · refunded · cancelled · expired (only active and completed grant content access. pending_payment holds the learner's place in the unique(user_id, course_id) slot so an abandoned checkout can be retried, and grants nothing. expired exists so the students screen can filter on it; nothing sets it yet.)

Lesson progress status: in_progress · submitted · completed (submitted = an assignment awaiting the mentor's grade)


Shared objects

Course object (mentor / authoring view)

{
  "id": "9b6f…",
  "mentor_id": "8a1c…",
  "community_ids": [],
  "title": "Mastering Product Management",
  "slug": "mastering-product-management",
  "subtitle": "From backlog to roadmap",
  "description": "…",
  "summary": "A concise overview of the course.",
  "learning_outcomes": ["Prioritise a backlog", "Build a roadmap", "Run discovery"],
  "requirements": ["A laptop", "Basic spreadsheet literacy"],
  "includes": [
    { "icon": "video", "label": "4 hours of on-demand video" },
    { "icon": "article", "label": "12 lessons" },
    { "icon": "mobile", "label": "Access on mobile and desktop" },
    { "icon": "lifetime", "label": "Full lifetime access" }
  ],
  "includes_is_custom": false,
  "category": "business",
  "category_label": "Business",
  "language": "english",
  "language_label": "English",
  "level": "intermediate",
  "level_label": "Intermediate",
  "has_certificate": false,
  "downloadable_resources_count": 0,
  "status": "draft",
  "status_label": "Draft",
  "visibility": "public",
  "visibility_label": "Public",
  "is_free": false,
  "price": "49.00",
  "currency": "USD",
  "is_exclusive_to_premium": false,
  "total_lessons": 12,
  "total_duration_minutes": 240,
  "enrollment_count": 0,
  "average_rating": "4.60",
  "rating_count": 5,
  "rating_breakdown": { "5": 3, "4": 2, "3": 0, "2": 0, "1": 0 },
  "thumbnail_url": null,
  "mentor": { "…mentor block…" },
  "is_enrolled": false,
  "is_wishlisted": false,
  "is_creator": true,
  "awaits_payment": false,
  "meta_title": null,
  "meta_description": null,
  "og_image_url": null,
  "faqs": [],
  "cta_label": null,
  "modules": [ { "…module object…" } ],
  "published_at": null,
  "created_at": "2026-07-02T09:00:00+00:00",
  "updated_at": "2026-07-02T09:00:00+00:00"
}

modules (with nested lessons) is included only on the show response. total_lessons / total_duration_minutes are recomputed from published lessons.

Field notes

Field Notes
requirements Mirrors learning_outcomes exactly — same storage, same validation, always an array, never null
includes The "This course includes" rows. Always populated: served verbatim when the author wrote their own, otherwise derived from total_duration_minutes / total_lessons / downloadable_resources_count / has_certificate. icon is one of video · article · download · quiz · assignment · certificate · lifetime · mobile
includes_is_custom bool. true when the rows are the author's own, false when derived. Present on both the mentor and public resources — it is what decides whether to show "Reset to automatic", since there is nothing to reset on a derived list
level Never null — defaults to all
rating_breakdown A JSON object keyed "5""1", all five keys present, zeros included. Recomputed on every review write. See the warning below
is_enrolled / is_wishlisted / is_creator / awaits_payment Always present, never omitted — a guest gets four falses. See Viewer flags
og_image_url Falls back to thumbnail_url when unset, so the share card is never blank
faqs { question, answer }[]. Always an array, never null
cta_label Overrides the enrol button's label. There is deliberately no cta_url — see Storefront

rating_breakdown is an object

{"5":3,"4":2,"3":0,"2":0,"1":0}not an array. Worth stating because it nearly shipped wrong: Laravel's JsonResource::filter() re-indexes any array whose keys are all numeric, and "5""1" are all numeric, so left as a PHP array this serialized as [3,2,0,0,0] — a list, in an order a client would have had to guess. It is cast to an object specifically to stay out of that code path. An array here is a bug, not a variant.

Viewer flags

Four flags appear on every course payload: browse (list), show by slug, my courses, the wishlist, and the nested course on my enrollments.

Flag True when
is_enrolled The caller has an active or completed enrollment
is_wishlisted The caller has saved the course
is_creator The caller's mentor profile owns the course
awaits_payment The caller has a pending_payment enrollment — an abandoned checkout

They are always present. A guest gets false four times rather than four missing keys — an absent flag is indistinguishable from false right up until a client writes !== false. All four cost two queries per page, not two per row; is_enrolled and awaits_payment come from the same enrollment query, because they are two answers about one row.

awaits_payment — the resume-payment state

An abandoned checkout leaves a real enrollment behind that grants no access, so is_enrolled is correctly false for that learner. Without a second flag they would be indistinguishable, on the course payload alone, from someone who never started — and the enrol button would offer to enrol somebody who is already half-enrolled.

awaits_payment: true is the cue to render the resume state ("CONTINUE TO PAYMENT · $49" rather than "ENROL · $49"). Re-posting to /enroll resumes the existing row with a refreshed price snapshot rather than colliding with it, so the button can point straight back at the same endpoint.

The two flags are never both true. exclude_enrolled agrees with them: a pending_payment course stays in Discover, because it is still something to buy.

The mentor block

"mentor": {
  "id": "8a1c…",
  "user_id": "4f2b…",
  "username": "janedoe",
  "name": "Jane Doe",
  "headline": "Product leader",
  "avatar_url": null,

  "bio": "…",
  "average_rating": "4.72",
  "reviews_count": 41,
  "students_count": 380,
  "courses_count": 6
}

id is the mentor profile id — compare it against user.mentor_profile.id to decide ownership — while username addresses the user and is what /user/{username} is built from. Keep the two apart; the same distinction applies to communities.

⚠️ The bottom five appear on a single-course read onlyshow by slug and mentor show — and are absent from every list. A discover page renders no instructor card, and four aggregate queries per row would be an N+1.

⚠️ mentor.average_rating is the mentor's course rating across their whole catalogue, over reviews_count course reviews. It is distinct from the course's own average_rating sitting next to it, and it is not their 1:1 session rating, which measures a different thing.

Module object

{
  "id": "1a2b…",
  "course_id": "9b6f…",
  "title": "Foundations",
  "description": null,
  "position": 0,
  "unlock_at_level": null,
  "is_locked": false,
  "is_published": true,
  "lessons_count": 4,
  "total_duration_minutes": 48,
  "lessons": [ { "…lesson object…" } ],
  "created_at": "…",
  "updated_at": "…"
}

lessons_count and total_duration_minutes are derived from the loaded lessons (or from a withCount, when the caller shaped the query for it) and never fire their own query — an unshaped read reports 0 rather than an N+1. They were added so the mentor and public module payloads agree field-for-field.

Lesson object (mentor / authoring view)

{
  "id": "3c4d…",
  "module_id": "1a2b…",
  "course_id": "9b6f…",
  "title": "What is a roadmap?",
  "lesson_type": "video",
  "lesson_type_label": "Video",
  "position": 0,
  "text_content": null,
  "video_provider": "youtube",
  "video_url": "https://www.youtube.com/watch?v=…",
  "duration_minutes": 12,
  "time_limit_minutes": null,
  "quiz_payload": { "pass_pct": 70, "questions": [ { "prompt": "…", "type": "single", "options": ["…"], "correct": [1], "explanation": "…" } ] },
  "assignment_payload": null,
  "is_preview": false,
  "is_published": true,
  "locked": false,
  "attachment_url": null,
  "assignment_attachments": [],
  "created_at": "…",
  "updated_at": "…"
}

locked is always false here: an author is never locked out of their own lesson. It is present so a client reads one lesson shape on both the authoring and public payloads.

time_limit_minutes (optional) applies to quiz and assignment lessons — see the timer notes. assignment_payload holds { total_marks, passing_marks, instructions }; assignment_attachments is the array of mentor-provided brief files ([{ id, name, url }]).

Pricing object (a layer-1 rate)

{
  "id": "7d2e…",
  "course_id": "9b6f…",
  "audience_type": "specific_community",
  "audience_type_label": "Specific Community",
  "is_deprecated": false,
  "label": "React Devs BD members",
  "community_id": "7f3e…",
  "community": { "id": "7f3e…", "name": "React Devs BD", "slug": "react-devs-bd" },
  "is_free": false,
  "price": "79.00",
  "currency": "USD",
  "starts_at": null,
  "ends_at": "2026-09-30T23:59:59+00:00",
  "is_active": true,
  "is_live": true,
  "priority": 0,
  "created_at": "…",
  "updated_at": "…"
}

is_active is the switch; is_live is the answer — active and inside its window right now. A scheduled rate is is_active: true, is_live: false. is_deprecated marks a row whose audience can no longer be created (§17); it stays editable and deletable.

my_pricing

On every course payload, always present, never null. A course with no rules resolves to its own price with rate: null — that is a real answer, not a missing one, so no client has to decide which price field beats which.

{
  "base_price": "99.00",
  "list_price": "79.00",
  "price": "63.20",
  "currency": "USD",
  "is_free": false,
  "audience_type": "specific_community",
  "audience_type_label": "Specific Community",
  "rate": { "rule_id": "7d2e…", "audience_type": "specific_community", "audience_type_label": "Specific Community", "label": "React Devs BD members", "community_id": "7f3e…", "community": { "id": "7f3e…", "name": "React Devs BD", "slug": "react-devs-bd" }, "starts_at": null, "ends_at": null },
  "offer": { "id": "0f1a…", "label": "Launch week", "discount_type": "percent", "discount_value": "20.00", "currency": null, "starts_at": null, "ends_at": "2026-09-01T23:59:59+00:00", "allows_coupons": true, "status": "live", "status_label": "Live" },
  "savings": "15.80",
  "savings_pct": 20
}
Field Means
base_price The course's own price. Context only — never the strikethrough
list_price After layer 1: what this viewer pays with no offer running. This is the strikethrough
price After layer 2: what they pay right now
savings list_price − price

Layer 3 never appears here — a coupon is quoted on demand, on §21. The flat is_free / price / currency beside this block are the course's own list price and are legacy: new code should read my_pricing.

Enrollment object

{
  "id": "5e6f…",
  "course_id": "9b6f…",
  "community_id": "7f3e…",
  "status": "active",
  "status_label": "Active",
  "audience_type": "public",
  "is_free": false,
  "price_snapshot": "49.00",
  "list_price_snapshot": "99.00",
  "offer_id": "0f1a…",
  "coupon_id": "3c4d…",
  "discount_amount": "50.00",
  "currency": "USD",
  "awaits_payment": false,
  "progress_pct": 50,
  "completed_lessons_count": 6,
  "enrolled_at": "2026-07-02T09:00:00+00:00",
  "completed_at": null,
  "last_accessed_at": "2026-07-02T10:00:00+00:00",
  "course": { "…public course object (when loaded)…" }
}

awaits_payment tells "enrolled" from "still owes money" without inferring it from the status string. It is true only for pending_payment.

price_snapshot is what is owed; list_price_snapshot, offer_id, coupon_id and discount_amount are why it is that number, so "you saved $15.80 with LAUNCH20" costs no second lookup on either the learner's enrollment or the author's sales list. They survive the offer or code being deleted afterwards.


Mentor endpoints — authoring

All require an approved mentor profile. Ownership is enforced by scoping: another mentor's course (or a missing mentor profile) returns 404 — never 403, which would confirm the course exists to someone with no claim to it.

{course} takes a slug or an id

Every mentor course route below, and every sub-resource under it — /publish, /unpublish, /archive, /modules, /pricing, /students, /submissions, /notices, /materials, /authors — resolves {course} as either the course UUID or its public slug.

The course page is addressed by slug, because that is what public links use. Bridging that client-side meant fetching the mentor's entire catalogue on every page load purely to map slug → id; accepting both here deletes that request.

1. List my courses

GET /api/v1/mentor/courses

Returns the mentor's courses (all statuses), newest first. Paginated.

Param Type Notes
search string Title / subtitle / summary / description / mentor name
status enum draft · published · archived
sort enum newest (default) · popular · rating · price_low · price_high · title
page int
per_page int 1–100 (default 15)

200{ "data": { "courses": { "data": [ … ], "links": { … }, "meta": { … } } } }

⚠️ Breaking. This returned a bare array until the course-system contract landed. Read data.courses.data; data.courses.meta carries current_page / last_page / total.

Every row carries the four viewer flags. is_creator is trivially true here, and saying so beats making the client infer it.

Self-only. This lists the caller's own courses. To read another user's courses — or to render a profile page — use GET /api/v1/user/{username}/courses (Authentication API, endpoint 42). That one is paginated, merges authored with enrolled courses behind is_creator / is_enrolled flags, and hides drafts and archived courses from everyone but the author.

2. Create a course

POST /api/v1/mentor/courses          (multipart/form-data if sending a thumbnail)

Creates a draft course and auto-generates a unique slug from the title.

Field Type Required Notes
title string yes Max 255
subtitle string no Max 255
description string no
summary string no Short overview
learning_outcomes string[] no Array of outcomes; each ≤ 255
requirements string[] no Same shape and rules as learning_outcomes
includes object[] no [{ icon, label }]. Omit and the list is derived from the counters
category enum no Slug — see Enums
language enum no Code — see Enums
level enum no Defaults to all
has_certificate bool no Default false
downloadable_resources_count int no Default 0
meta_title string no ≤ 255. Storefront — <title> and share-card headline
meta_description string no ≤ 500. Search snippet and share-card body
og_image file no Image ≤ 5 MB. Returns og_image_url
faqs object[] no [{ question, answer }]
cta_label string no ≤ 100. Overrides the enrol button label
community_ids uuid[] conditionally Communities carrying the course — each must be one you own. At least one required when visibility = community_only
community_id uuid no Deprecated — single-community alias, folded into community_ids
visibility enum no public (default) · unlisted · community_only
is_free bool no Default false
price decimal conditionally Required unless is_free is true
currency ignored Derived from the mentor's profile currency. Not accepted; a value sent here is stripped by validation.
is_exclusive_to_premium bool no Stored; not yet enforced
thumbnail file no Image, ≤ 5 MB

201{ "data": { "course": { "…course object…" } } } Errors: 422 missing title / missing price when not free / a community_ids entry not owned by you / community_only with no communities · 404 no mentor profile

3. Show a course

GET /api/v1/mentor/courses/{course}          ← slug or id

Returns the course with its modules → lessons eager-loaded (all statuses; authoring view), plus the pricing rows and the full mentor block including instructor-card stats.

200{ "data": { "course": { "…course object with modules…" } } } Errors: 404 not found / not owned

4. Update a course

PUT|PATCH /api/v1/mentor/courses/{course}     ← slug or id
                                              (multipart/form-data if sending a file)

Same fields as create, all optional (sometimes). Changing title regenerates the slug.

Send as multipart/form-data with _method=put when attaching a thumbnail or og_image — PHP cannot read files from a real PUT body.

visibility, is_free, price and currency are all accepted here, not only on create.

Empty lists mean "delete every row"

learning_outcomes, requirements, faqs and includes all follow one rule:

What you send What happens
key absent the stored list is left untouched
requirements[]="" (only empty strings) the list is cleared
requirements[]=A&requirements[]= collapses to ["A"]

An HTML form cannot send an absent array, so a list containing only empty strings is how the client says "the author deleted every row". Without this the old rows survive the deletion.

includes and faqs are lists of objects, so they are written as includes[0][icon] / includes[0][label] — the same two-level shape faqs uses — while learning_outcomes, requirements and tags are flat string lists. The clear signal is the same []="" for all five.

Pass community_ids to replace the whole set of communities carrying the course; omit the key to leave the links untouched, or send [] to unlink it entirely. The community-only invariant is checked against the resulting state: switching visibility to community_only on a course no community carries (and none supplied) returns 422, as does clearing the last community of a course that is already community_only.

200{ "data": { "course": { … } } } Errors: 422 a community_ids entry not owned by you / community_only with no communities

5–7. Lifecycle: publish / unpublish / archive

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

An archived course behaves like an unpublished one for everyone but its owner: it drops out of public browse and slug lookup, since both filter on published.

200{ "data": { "course": { … } } }

8. Delete a course

DELETE /api/v1/mentor/courses/{course}

Cascades to modules, lessons, pricing, enrollments, progress, reviews, authors, notices, materials, notes, and comments.

200{ "message": "Course deleted successfully." }


Mentor endpoints — modules

Nested under a course the mentor owns. A course not owned by the caller → 404.

9. List / create modules

GET  /api/v1/mentor/courses/{course}/modules          → { "data": { "modules": [ … with lessons ] } }
POST /api/v1/mentor/courses/{course}/modules          → 201 { "data": { "module": { … } } }

Create payload

Field Type Required Notes
title string yes Max 255
description string no
position int no Auto = max(position) + 1 when omitted
unlock_at_level int no ≥ 0; locks the module until the learner reaches this level in the community they enrolled through. 0 is the ladder floor — open to every member, still shut to non-members. Rejected (422) unless a community carries the course (see Community linkage)
is_published bool no Default true

10. Update / delete a module

PUT|PATCH /api/v1/mentor/courses/{course}/modules/{id}     → { "data": { "module": { … } } }
DELETE    /api/v1/mentor/courses/{course}/modules/{id}     → { "message": "Module deleted successfully." }

11. Reorder modules

PATCH /api/v1/mentor/courses/{course}/modules/reorder

Sets position to each id's index in the array (0-based). Ids not belonging to the course are ignored.

{ "ordered_ids": ["moduleB", "moduleA", "moduleC"] }

200{ "data": { "modules": [ … in new order ] } } Errors: 422 empty / non-uuid ordered_ids


Mentor endpoints — lessons

Nested under a module. Creating or deleting a lesson recomputes the course's total_lessons / total_duration_minutes (from published lessons).

12. List / show / create lessons

GET  /api/v1/mentor/courses/{course}/modules/{module}/lessons        → { "data": { "lessons": [ … ] } }
GET  /api/v1/mentor/courses/{course}/modules/{module}/lessons/{id}   → { "data": { "lesson": { … } } }
POST /api/v1/mentor/courses/{course}/modules/{module}/lessons        → 201 { "data": { "lesson": { … } } }

Create payload (multipart/form-data when uploading files)

Field Type Required Notes
title string yes Max 255
lesson_type enum yes video · text · pdf · quiz · assignment
position int no Auto-positioned when omitted
text_content string for text Article body
video_provider string no e.g. youtube
video_url url for video Max 2048
duration_minutes int no Feeds the course total
time_limit_minutes int no ≥ 1; timer for quiz / assignment (see Timer)
quiz_payload object for quiz See below
assignment_payload object for assignment See below
attachment file for pdf mimes:pdf, ≤ 10 MB
assignment_attachments file[] no Brief files for assignment; pdf,doc,docx,ppt,pptx,xls,xlsx,zip,png,jpg,jpeg, ≤ 10 MB each
is_preview bool no Free teaser (shown to non-enrolled)
is_published bool no Default true

Content requirement by type (enforced): videovideo_url; texttext_content; pdfattachment; quizquiz_payload; assignmentassignment_payload.

Quiz payload shape

{
  "pass_pct": 70,
  "questions": [
    { "prompt": "2 + 2?", "type": "single", "options": ["3", "4", "5"], "correct": [1], "explanation": "…" }
  ]
}

correct is an array of the correct option indices (supports multi-select).

type is single or multiple — how the question is answered, and therefore whether the learner is drawn radios or checkboxes. Anything else is a 422.

Omit it and it is filled in from the answer key: two or more correct indices means multiple, otherwise single. So it is optional on the wire and always present in storage. It is the author's to choose, though — a single-answer question with one right option out of five is still legitimately multiple if that is what you send.

Assignment payload shape

{ "total_marks": 100, "passing_marks": 50, "instructions": "Complete the attached brief." }

passing_marks must be ≤ total_marks (validated). Upload the brief files the learner works from as assignment_attachments[]. The learner uploads their solution via Submit an assignment; the mentor then grades it (see assignment submissions & grading).

13. Update / delete a lesson

PUT|PATCH /api/v1/mentor/courses/{course}/modules/{module}/lessons/{id}
DELETE    /api/v1/mentor/courses/{course}/modules/{module}/lessons/{id}

Update fields are all optional; on update the course totals are recomputed.

14. Reorder lessons

PATCH /api/v1/mentor/courses/{course}/modules/{module}/lessons/reorder

Same contract as module reorder, scoped to the module.

{ "ordered_ids": ["lessonB", "lessonA"] }

Mentor endpoints — assignment submissions & grading

Nested under an assignment lesson the mentor owns. Learners submit files via endpoint 29; these endpoints let the mentor review and grade them.

Submission object

{
  "id": "6f7a…",
  "enrollment_id": "5e6f…",
  "lesson_id": "3c4d…",
  "status": "submitted",
  "status_label": "Submitted",
  "learner": { "id": "8a1c…", "name": "Sam Lee", "email": "[email protected]" },
  "note": "Here is my work.",
  "file_url": "https://…/solution.pdf",
  "is_late": false,
  "submitted_at": "2026-07-03T09:00:00+00:00",
  "awarded_marks": null,
  "graded_at": null,
  "feedback": null
}

15. List submissions

GET /api/v1/mentor/courses/{course}/modules/{module}/lessons/{lesson}/submissions

Returns every learner submission for the assignment lesson, newest first (each with the submitting learner and their uploaded file).

200{ "data": { "submissions": [ { "…submission object…" } ] } } Errors: 404 lesson not found / not owned

16. Grade a submission

PATCH /api/v1/mentor/courses/{course}/modules/{module}/lessons/{lesson}/submissions/{submission}/grade

Records the awarded marks. A pass (awarded_marks ≥ passing_marks) marks the lesson completed and rolls up the enrollment; a fail returns it to in_progress so the learner may resubmit.

Payload

Field Type Required Notes
awarded_marks int yes ≥ 0 and ≤ total_marks
feedback string no Max 2000

200

{
  "data": {
    "passed": true,
    "submission": { "…submission object, now graded…" }
  }
}

Errors: 422 awarded_marks exceeds the lesson's total_marks · lesson isn't an assignment · submission not yet submitted · 404 lesson / submission not found


Mentor endpoints — pricing

Three layers plus a code, each producing at most one winner, applied in order:

  base price on the course          $99    what everyone pays, signed in or out
        ↓  a rate may replace it entirely
  LAYER 1 · rate                    $79    "React Devs BD members pay 79"     §17–18
        ↓  an offer discounts whatever won layer 1
  LAYER 2 · offer                   $63.20 "20% off, launch week"             §18a
        ↓  a coupon discounts whatever won layer 2
  LAYER 3 · coupon                  $56.88 the learner types EARLYBIRD        §18b

The base price is the price for everyone, not a fallback: a rate is the exception to it. Every layer is resolved for every viewer on every course payload — see my_pricing — except the coupon, which is quoted on demand (§21).

Why two audiences were retired. public duplicated the base price — a rate for everyone is the base price by another name — and community_member meant "any community", which priced a course for members of communities that had nothing to do with it. Both are rejected on write; existing rows are served but never win. The tiebreak that replaced them resolves most specific first, so a rate naming this community beats a broader one.

17. List / create pricing (layer 1 — rates)

GET  /api/v1/mentor/courses/{course}/pricing     → { "data": { "pricing": [ { …rate object… } ] } }
POST /api/v1/mentor/courses/{course}/pricing     → 201 { "data": { "pricing": { … } } }

Create payload

Field Type Required Notes
audience_type enum yes specific_community · any_community_member. See the deprecation note below
label string no The author's own name for the rate — "React Devs BD members"
community_id uuid conditionally Required for specific_community, forbidden for any_community_member — and must be a community you own
is_free bool no
price decimal conditionally Required unless is_free is true
currency ignored Derived from the mentor's profile currency. Not accepted; a value sent here is stripped by validation.
starts_at datetime no Null = open-ended
ends_at datetime no Null = open-ended. Must fall after starts_at
is_active bool no Default true. A switched-off rate applies to nobody
priority int no Breaks ties between rates that cost the same. Higher wins

Rate object adds is_deprecated, is_live (active and inside its window right now) and a community object (id, name, slug) so a four-row list is readable without resolving community_id client-side.

Two audiences are deprecated

public and community_member are deprecated-but-readable: existing rows keep resolving, stay editable and stay deletable — deleting one falls back to the base price, which is the correct migration — but creating a new one is a 422 on audience_type.

  • public replaces the base price for every viewer, which leaves the author's Base price field reading the old number while everyone pays the rate, and under lowest-price-first it silently outranks every narrower rate below it. A windowed price for everyone is an offer (§18a), which does it better: one row, a countdown, and a struck-through list_price.
  • community_member requires a community_id, making it an exact duplicate of specific_community. any_community_member — a member of any community carrying the course — is the broad audience it was mistaken for.

Overlap is now an application check

The UNIQUE (course_id, audience_type, community_id) index is gone: it made windowed rates impossible to express. Two rates for the same audience whose windows overlap while both are active are a 422 keyed on ends_at (or starts_at for an open-ended rate), so the message renders under the control that caused it. A paused rate never collides with anything.

Errors: 422 a deprecated audience · a community audience without a community · a community_id you don't own · an overlapping window.

18. Update / delete pricing

PUT|PATCH /api/v1/mentor/courses/{course}/pricing/{id}     → { "data": { "pricing": { … } } }
DELETE    /api/v1/mentor/courses/{course}/pricing/{id}     → { "message": "Pricing deleted successfully." }

Partial — the list's pause switch sends { "is_active": false } alone. audience_type and community_id are not editable: re-pointing a live rate at a different audience reprices a course silently. Delete it and create a new one instead.

18a. Offers (layer 2)

GET    /api/v1/mentor/courses/{course}/offers          → { "data": { "offers": [ { …offer… } ] } }
POST   /api/v1/mentor/courses/{course}/offers          → 201 { "data": { "offer": { … } } }
PUT    /api/v1/mentor/courses/{course}/offers/{offer}  → { "data": { "offer": { … } } }
DELETE /api/v1/mentor/courses/{course}/offers/{offer}  → { "message": "Offer deleted successfully." }

An offer discounts whatever rate won layer 1 rather than replacing it, so list_price survives to be struck through.

Field Type Required Notes
label string yes Shown to the learner beside the discounted price
discount_type enum yes percent · fixed
discount_value decimal yes > 0; ≤ 100 for a percentage
currency ignored Derived from the course's currency for fixed, and null for percent. Not accepted.
starts_at / ends_at datetime no Null = open-ended. A bare YYYY-MM-DD ends_at means the end of that day
is_active bool no Default true
allows_coupons bool no Default true. false means the offer is already the floor — a code applied over it is a 422
priority int no Breaks ties between offers worth the same

status is server-derivedlive · scheduled · expired · paused. Offers may overlap freely: the resolver takes whichever discounts the viewer's rate the most.

18b. Coupons (layer 3)

GET    /api/v1/mentor/courses/{course}/coupons            → { "data": { "coupons": [ { …coupon… } ] } }
POST   /api/v1/mentor/courses/{course}/coupons            → 201 { "data": { "coupon": { … } } }
PUT    /api/v1/mentor/courses/{course}/coupons/{coupon}   → { "data": { "coupon": { … } } }
DELETE /api/v1/mentor/courses/{course}/coupons/{coupon}   → { "message": "Coupon deleted successfully." }

A course with no codes is a 200 with "coupons": [] — never a 404.

Field Type Required Notes
code string yes Letters, numbers, hyphens, underscores. Stored upper-cased; redeem is case-insensitive
discount_type enum yes percent · fixed
discount_value decimal yes > 0; ≤ 100 for a percentage
currency ignored Derived from the course's currency for fixed, and null for percent. Not accepted.
max_redemptions int no Null = unlimited
per_user_limit int no Null = unlimited
starts_at / expires_at date no YYYY-MM-DD. Read as the start and the end of that day, so a code expiring "on the 30th" works all of the 30th
is_active bool no Default true

status is server-derivedlive · scheduled · expired · used_up · paused — so the author's badge and the redeem path cannot disagree. redemptions_count moves on payment capture, not on checkout creation.

Deleting a code does not touch enrollments made with it: the enrollment keeps its coupon_id, list_price_snapshot and discount_amount as the record of what was charged.

Errors: 422 duplicate code on this course (keyed on code) · validation.


Public endpoints — discovery

No authentication required.

19. Browse courses

GET /api/v1/public/courses

Lists published + public courses only (drafts, unlisted, and community_only are excluded). Paginated.

Optional auth. Works without a token; a bearer token is read when present and populates the four viewer flags on every row.

Query parameters

Param Type Notes
search string Matches title / subtitle / summary / description / the mentor's name and username
is_free bool Filter by free/paid
mentor_id uuid Courses by one mentor
category enum Slug — see Enums
language enum Code — see Enums
level enum beginner · intermediate · advanced · all
sort enum newest (default) · popular (enrolment count) · rating · price_low · price_high
exclude_enrolled bool Drop courses the caller is enrolled in or owns
page int
per_page int 1–100 (default 15)

An unsupported value is a 422, not a silent ignore. ?level=expert errors rather than quietly returning the unfiltered list — a discarded filter returns the wrong list, which is worse than an error.

exclude_enrolled

Applied in SQL, before pagination, so meta.total and the row count always agree. Filtering client-side is what let page 1 render 4 cards while meta.total said 12.

  • A pending_payment enrolment is not excluded — the learner still owes money and has no access, so the course is still something to discover.
  • Courses the caller owns are excluded too.
  • For a guest the filter is a harmless no-op: they own and are enrolled in nothing.

200 Response

{
  "status": "success",
  "message": "Courses retrieved successfully.",
  "data": {
    "courses": {
      "data": [ { "…public course object…" } ],
      "links": { "…" },
      "meta": { "current_page": 1, "last_page": 1, "total": 2, "per_page": 15 }
    }
  }
}

Errors: 422 invalid sort, category, language, level, or exclude_enrolled

19a. Course filter vocabularies

GET /api/v1/public/course-filters

The vocabularies behind the discover selects and the Settings tab, mirroring /public/community-filters. Served from the enums rather than from a distinct-values query, so an empty catalogue still offers a full set of choices — and so an author can never pick a category the server does not recognise.

200

{
  "status": "success",
  "message": "Course filters retrieved successfully.",
  "data": {
    "categories": [ { "slug": "development", "name": "Development" } ],
    "languages":  [ { "code": "english", "name": "English" } ],
    "levels":     [ { "value": "beginner", "label": "Beginner" } ]
  }
}

20. Show a course by slug

GET /api/v1/public/courses/{slug}

Returns a published public or unlisted course with its published modules → lessons. community_only, draft, and archived courses return 404.

Optional auth. The endpoint works without a token, but a bearer token is read when present and populates the four viewer flags and per-lesson progress.

⚠️ Breaking. is_enrolled and is_wishlisted used to be omitted entirely for guests. All three flags are now always present, false for a guest. Any client keying off their presence rather than their value needs updating.

Also carries created_at / updated_at alongside published_at, so "Updated {month year}" is actually last-updated rather than falling back to the publish date.

Public lesson gating: non-is_preview lessons are returned with "locked": true and their content fields (text_content, video_url, quiz_payload, attachment_url) omitted. Preview lessons expose their content.

locked stays preview-based even for an enrolled learner — this payload is the sales-page outline, and the content they are entitled to comes from Get course content.

Per-lesson progress (enrolled callers only). Each lesson carries { status, status_label, completed_at } | null, so an enrolled learner opening the public page sees their completion ticks instead of a blank outline. One query for the whole curriculum. A guest — and an enrolled learner who has not touched a lesson — gets null.

200 (excerpt)

{
  "data": {
    "course": {
      "id": "9b6f…", "title": "…", "slug": "…", "is_free": false, "price": "49.00",
      "level": "intermediate", "level_label": "Intermediate",
      "language": "english", "language_label": "English",
      "requirements": ["A laptop"],
      "rating_count": 5,
      "rating_breakdown": { "5": 3, "4": 2, "3": 0, "2": 0, "1": 0 },
      "is_enrolled": true, "is_wishlisted": false, "is_creator": false,
      "awaits_payment": false,
      "mentor": {
        "id": "8a1c…", "user_id": "4f2b…", "username": "janedoe", "name": "Jane Doe",
        "headline": "…", "avatar_url": null,
        "bio": "…", "average_rating": "4.72", "reviews_count": 41,
        "students_count": 380, "courses_count": 6
      },
      "modules": [
        { "id": "1a2b…", "title": "Foundations", "lessons_count": 2,
          "lessons": [
            { "id": "3c4d…", "title": "Preview", "lesson_type": "video", "locked": false,
              "is_preview": true, "video_url": "https://…",
              "progress": { "status": "completed", "status_label": "Completed",
                            "completed_at": "2026-08-01T10:00:00+00:00" } },
            { "id": "4d5e…", "title": "Locked", "lesson_type": "video", "locked": true,
              "is_preview": false, "progress": null }
          ] }
      ]
    }
  }
}

Errors: 404 not found / not directly accessible · 500 unexpected failure

Opening a preview lesson

GET /api/v1/public/courses/{slug}/lessons/{lesson}     ← no auth required

Serves the same shape the classroom route serves, minus progress — the key is omitted, not null, because there is no enrollment to read one from.

404 (never 403 — a 403 would confirm that paid content exists at that address) when the lesson is not is_preview, when it or its module is unpublished, when the course is unpublished, and when the lesson belongs to a different course.

Answer keys are stripped here too, on this endpoint and on the curriculum outline above: correct and explanation never reach a guest. Each question's type does — the preview is drawn by the same component the classroom uses.


Wishlist endpoints — saved courses

Requires a Sanctum bearer token.

The wishlist is per user and global to the course. Saving a course once saves it everywhere it appears — a community only ever narrows which saved courses it lists (see Community API §17h). That is why these routes are not scoped to a community.

{course} accepts either a course UUID or the public {slug}, so the public course page can toggle the heart with the slug it already has in the URL.

20a. List my wishlist

GET /api/v1/courses/wishlist

Every course the caller has saved, newest save first. Paginated.

Query parameters

Param Type Notes
page int
per_page int 1–100 (default 15)

Each row carries all four viewer flags — including awaits_payment, so a saved course with an abandoned checkout renders the resume state here too. is_wishlisted is always true on this screen by definition.

200 Response

{
  "status": "success",
  "message": "Wishlisted courses retrieved successfully.",
  "data": {
    "courses": {
      "data": [
        {
          "id": "9b6f…", "title": "…", "slug": "from-junior-to-senior", "subtitle": "…",
          "status": 2, "status_label": "Published",
          "visibility": 1, "visibility_label": "Public",
          "total_lessons": 12, "total_duration_minutes": 240,
          "enrollment_count": 87, "average_rating": "4.60", "thumbnail_url": null,
          "is_free": false, "price": "49.00", "currency": "USD",
          "mentor": { "id": "8a1c…", "username": "jane", "name": "Jane Doe",
                      "headline": "…", "avatar_url": null },
          "is_wishlisted": true,
          "is_enrolled": false,
          "wishlisted_at": "2026-08-12T09:14:07+00:00"
        }
      ],
      "links": { "…" },
      "meta": { "current_page": 1, "last_page": 1, "total": 1, "per_page": 15 }
    }
  }
}

is_wishlisted is always true here — it is kept so the card shares one shape with the community catalogue (§16) and the heart can be un-toggled in place. is_enrolled reflects an active or completed enrollment, so the card can render Continue instead of Enroll.

Errors: 401 unauthenticated · 422 per_page out of range

20b. Save a course

POST /api/v1/courses/{course}/wishlist

200 Response

{
  "status": "success",
  "message": "Course added to your wishlist.",
  "data": { "is_wishlisted": true }
}

Idempotent — saving twice is not an error and never creates a second row; the response always reports the resulting state.

Errors: 401 unauthenticated · 404 no such course · 500 unexpected failure

20c. Remove a course

DELETE /api/v1/courses/{course}/wishlist

200 Response

{
  "status": "success",
  "message": "Course removed from your wishlist.",
  "data": { "is_wishlisted": false }
}

Idempotent — removing a course that was never saved returns 200 with "is_wishlisted": false. Only the caller's own save is removed.

Errors: 401 unauthenticated · 404 no such course · 500 unexpected failure


Mentee endpoints — enrollment

All require a Sanctum token.

21. Resolve pricing for me / quote a code

GET  /api/v1/mentee/courses/{course}/pricing                              → { "data": { "pricing": { … } } }
POST /api/v1/mentee/courses/{course}/pricing   { "coupon_code": "…" }     → { "data": { "pricing": { … } } }

The GET is the POST with no code typed — same resolver, same response shape, same data.pricing key, so one client type covers the price badge and the checkout box alike.

Layer 1 returns the minimum of the rates matching this viewer; ties break on priority, then on the more specific audience, then on age. Layer 2 applies the running offer worth the most. Layer 3 applies the code, if one was sent. Non-members and anonymous callers resolve to the base price. See Community linkage.

200

{
  "data": {
    "pricing": {
      "base_price": "99.00",
      "list_price": "79.00",
      "price_before_coupon": "63.20",
      "price": "56.88",
      "currency": "USD",
      "is_free": false,
      "audience_type": "specific_community",
      "audience_type_label": "Specific Community",
      "rate": { "rule_id": "01a0…", "label": "React Devs BD members", "community": { "id": "01a0…", "name": "React Devs BD" }, "…": "…" },
      "offer": { "id": "01a0…", "label": "Launch week", "ends_at": "…", "allows_coupons": true, "status": "live", "…": "…" },
      "coupon": { "id": "01a0…", "code": "EARLYBIRD", "discount_type": "percent", "discount_value": "10.00", "currency": null },
      "coupon_discount_amount": "6.32",
      "discount_amount": "22.12",
      "savings": "22.12",
      "savings_pct": 28
    }
  }
}

list_price is the strikethrough — the price this viewer would otherwise have paid. base_price is context only: striking through a price someone was never eligible for advertises a discount that was never on offer to them.

A bad code is a 422 keyed on coupon_code

Never a 200 with a null coupon. errors.coupon_code[0] is written to be rendered verbatim under the learner's input.

Case Message
No such code on this course That code isn't valid for this course.
Expired, not started, or paused That code has expired.
max_redemptions reached That code has been fully redeemed.
per_user_limit reached You've already used that code.
Offer has allows_coupons: false Codes can't be combined with the current offer.
Already free for this viewer This course is already free for you.

Errors: 404 course not found · 422 as above

22. Enroll

POST /api/v1/mentee/courses/{course}/enroll

Enrolls the user. Free (or free-for-audience) courses grant access immediately and increment enrollment_count. Paid courses record a pending_payment enrollment that grants nothing, snapshot the resolved price, and return a checkout_url. Access, the counter bump and any XP all land when the payment settles.

Body (optional)

Field Type Required Rules
community_id uuid no Enroll through this community — it must carry the course and have you as an active member. Omitted, the carrying community where you hold the highest level is chosen
coupon_code string no The code, not a price. The quote (§21) is advisory: the code is re-validated and the price recomputed here, and a code that died in between fails the enrol with the same 422 shape

A zero-price enrollment — a free course, a free rate, or a discount that took the whole price — is granted outright: checkout_url is null and no payment provider is involved. The enrollment echoes list_price_snapshot, offer_id, coupon_id and discount_amount alongside price_snapshot.

The chosen community is recorded on the enrollment and fixes the price charged, the level context for module gating, and where progress XP lands. See Community linkage.

201

{
  "status": "success",
  "message": "Checkout started. Complete payment to unlock the course.",
  "data": {
    "enrollment": { "…enrollment object, status pending_payment, awaits_payment true…" },
    "checkout_url": "https://…"
  }
}

checkout_url is null for a free enrollment. Redirect whenever it is present, and never read is_free to decide — the response is the authority, so a course whose price changed mid-session still routes to payment. This mirrors the community join flow.

Resuming an abandoned checkout. course_enrollments is unique on (user_id, course_id), so re-posting to /enroll reuses the outstanding pending_payment row with a refreshed price snapshot rather than colliding with it. No enrolled_at, no counter bump and no XP are awarded until payment settles — otherwise a learner could farm XP by starting checkouts they never pay for. Render the resume-payment state off awaits_payment.

Errors

Code When
422 Already enrolled · course not published · community_only course and you're not an active member of any carrying community · supplied community_id does not carry the course or you're not an active member of it
502 The gateway refused to open a checkout. The pending_payment enrollment survives, so retrying picks it up
404 Course not found
401 Unauthenticated

Active members of any community carrying a community_only course can enroll (that community's member price applies); only outsiders are blocked.

23. List / show my enrollments

GET /api/v1/mentee/courses/enrollments            → paginated { "data": { "enrollments": { data, links, meta } } }
GET /api/v1/mentee/courses/enrollments/{id}       → { "data": { "enrollment": { …incl. course… } } }

Query (list): search (matched against the course — title / subtitle / summary / description / mentor name), status (enrollment status), page, per_page (default 15). Errors (show): 404 not the caller's enrollment.

The nested course on each row carries the four viewer flags, so a card can un-toggle the heart — or render the resume-payment state — without a second call.

Self-only, and enrollment-shaped. These return enrollment records (progress, price snapshot, status) for the caller. To list the courses another user is enrolled in, use GET /api/v1/user/{username}/courses?type=enrolled (Authentication API, endpoint 42) — it returns course objects, not enrollments, and carries no progress or pricing detail.


Mentee endpoints — learning & progress

Access requires an active/completed enrollment for the course; otherwise 403 (content) or 404 (progress actions resolve the enrollment first).

24. Get course content

GET /api/v1/mentee/courses/{course}/content

Returns the full unlocked curriculum (published modules → lessons) with the learner's per-lesson progress, plus the enrollment. Touches last_accessed_at.

Quiz safety: the learner content strips each quiz question's correct and explanation keys — answer keys are never sent to the client. Assignment lessons expose their brief (total_marks, passing_marks, instructions, attachments) but no grading internals.

type survives the strip. It is not part of the answer key — it says whether the question takes one option or several, which is what the client draws radios or checkboxes from. A quiz written before the field existed has its type derived from the answer key on the way out, so old courses render correctly too and nothing needs backfilling.

200 (excerpt)

{
  "data": {
    "course": {
      "id": "9b6f…", "title": "…",
      "modules": [
        { "id": "1a2b…", "title": "Foundations",
          "lessons": [
            { "id": "3c4d…", "title": "Intro", "lesson_type": "video", "video_url": "https://…",
              "time_limit_minutes": null, "quiz": null, "assignment": null,
              "progress": { "status": "completed", "completed_at": "…", "last_position_seconds": 620, "quiz_score_pct": null } },
            { "id": "4d5e…", "title": "Quiz", "lesson_type": "quiz", "time_limit_minutes": 30,
              "quiz": { "pass_pct": 70, "questions": [ { "prompt": "…", "type": "single", "options": ["…"] } ] },
              "assignment": null, "progress": null },
            { "id": "5e6f…", "title": "Assignment", "lesson_type": "assignment", "time_limit_minutes": 60,
              "quiz": null,
              "assignment": { "total_marks": 100, "passing_marks": 50, "instructions": "…",
                "attachments": [ { "id": "…", "name": "brief.pdf", "url": "https://…" } ] },
              "progress": null }
          ] }
      ]
    },
    "enrollment": { "…enrollment object…" }
  }
}

The progress object also carries assignment/timer fields when present: started_at, submitted_at, submission_note, submission_file_url, is_late, awarded_marks, graded_at, grade_feedback.

Errors: 403 not enrolled

25. Get a single lesson

GET /api/v1/mentee/courses/{course}/lessons/{lesson}

Returns one lesson's full content with this learner's progress attached. The same quiz-answer stripping as endpoint 24 applies — correct and explanation go, each question's type stays. Only published lessons of published modules are reachable; a lesson inside a level-locked module is withheld. Touches last_accessed_at.

200

{
  "data": {
    "lesson": {
      "id": "3c4d…", "module_id": "1a2b…", "title": "Intro",
      "lesson_type": "video", "lesson_type_label": "Video",
      "position": 1, "duration_minutes": 12, "time_limit_minutes": null, "is_preview": false,
      "text_content": null, "video_provider": "youtube", "video_url": "https://…",
      "quiz": null,
      "assignment": null,
      "attachment_url": null,
      "progress": { "status": "completed", "completed_at": "…", "last_position_seconds": 620, "quiz_score_pct": null }
    }
  }
}

Errors: 404 not enrolled / lesson not found (or unpublished) · 403 lesson's module is locked

26. Mark a lesson complete

POST /api/v1/mentee/courses/{course}/lessons/{lesson}/complete

Marks the lesson completed (idempotent) and rolls up the enrollment: completed_lessons_count, progress_pct (floor(completed / published_total × 100)), and — at 100% — flips the enrollment to completed and sets completed_at.

200{ "data": { "enrollment": { … updated … } } } Errors: 404 not enrolled / lesson not in course · 422 lesson not part of the course

27. Start a lesson (timer)

POST /api/v1/mentee/courses/{course}/lessons/{lesson}/start

Stamps started_at on the learner's progress so a timed lesson's window can be enforced. Required before submitting a quiz/assignment that has a time_limit_minutes. Calling it again does not reset the clock. Untimed lessons don't need it.

200{ "data": { "started_at": "2026-07-03T09:00:00+00:00", "time_limit_minutes": 30 } } Errors: 404 not enrolled / lesson not found

28. Submit a quiz

POST /api/v1/mentee/courses/{course}/lessons/{lesson}/quiz

Scores the submission and, on a pass (score ≥ pass_pct), marks the lesson complete. A question is correct when the selected indices exactly match the expected set. If the lesson has a time_limit_minutes, it must have been started and the window not exceeded — otherwise 422 (a quiz is a hard cutoff; see Timer).

Payload

{ "answers": [ [1], [0, 2] ] }

answers[i] is the array of selected option indices for question i.

200

{
  "data": {
    "score_pct": 100,
    "passed": true,
    "enrollment": { "…enrollment object…" }
  }
}

Errors: 422 lesson is not a quiz · not started / time limit exceeded · 404 not enrolled / lesson not found

29. Submit an assignment

POST /api/v1/mentee/courses/{course}/lessons/{lesson}/assignment     (multipart/form-data)

Uploads the learner's solution file and sets the lesson submitted, awaiting the mentor's grade. Resubmitting replaces the file and clears any prior grade.

A submission past the lesson's time_limit_minutes is still accepted but flagged is_late: true, and the note becomes required as the reason (see Timer).

Payload

Field Type Required Notes
file file yes pdf,doc,docx,ppt,pptx,xls,xlsx,zip,png,jpg,jpeg, ≤ 10 MB
note string conditionally Optional normally; required when the submission is late

201

{
  "data": { "submission": { "…submission object (status: submitted)…" } }
}

Errors: 422 lesson is not an assignment · late submission without a note · missing/invalid file · 404 not enrolled / lesson not found

30. Save video position

PATCH /api/v1/mentee/courses/{course}/lessons/{lesson}/position

Persists a resume position without completing the lesson (won't demote an already-completed lesson).

Payload

{ "position_seconds": 125 }

200{ "message": "Position saved." } Errors: 422 missing/negative position_seconds · 404 not enrolled


Progress model reference

enroll ─► ACTIVE ──(mark complete / pass quiz / assignment graded pass)──► progress_pct rises
                        │
                        └─(all published lessons complete)─► COMPLETED (+ completed_at)
  • progress_pct = floor(completed_lessons / published_lesson_count × 100).
  • A lesson counts as completed via mark-complete, a passing quiz, or an assignment graded as a pass.
  • video/text/pdf lessons complete via endpoint 26; quiz lessons complete by passing endpoint 28 (or can be force-completed via 26).
  • assignment lessons go in_progresssubmitted (learner uploads via endpoint 29) → completed when the mentor grades a pass (endpoint 16). A graded fail drops back to in_progress so the learner can resubmit.

Timer / time limits

A quiz or assignment lesson may carry a time_limit_minutes. Enforcement is server-side, keyed off started_at (set by endpoint 27):

  • No time_limit_minutes → no timer; start isn't needed.
  • Quiz (hard cutoff) — the lesson must be started, and a submission after started_at + time_limit_minutes is rejected with 422.
  • Assignment (late allowed) — a late submission is accepted but flagged is_late: true and its note becomes required (the reason for lateness). The mentor sees is_late when grading.

Community linkage

A course can be carried by several communities, priced independently in each. Three pieces combine; all are live.

1. Carrying communities — community_course pivot. The mentor sets the set via community_ids on create/update (§2, §4); every entry must be a community they own, and at least one is required for community_only visibility. The set drives:

  • Visibilitycommunity_only courses stay hidden from public browse/slug lookup and are surfaced only through a carrying community's gated catalog (GET /{mentee|mentor}/communities/{community}/courses — same endpoint on both surfaces). Enrolling requires active membership of any carrying community.
  • Level gatingunlock_at_level is only accepted on a course some community carries; levels are per-community, so an unlinked course has no level to check against.

2. Enrolling community — course_enrollments.community_id. Because levels, XP and price are all per-community, an enrollment records which community the access came through. It is fixed at enrollment and drives:

⚠️ The community level ladder was renumbered — the floor is now level 0 and tiers start at 1. Stored unlock_at_level values were shifted down by one alongside the thresholds they point at, so existing gates still admit exactly the same learners. See the breaking-change note in community-api.md.

  • Module locking — a module's unlock_at_level locks it until the learner reaches that level in the enrolling community. In learner content, locked modules are withheld.
  • XPlesson_completed (+20) / course_completed (+100) are awarded in the enrolling community only. Awarding across every carrying community would multiply one lesson's XP.
  • Price — see below.

Resolution order when enrolling: an explicit community_id in the request body (which must both carry the course and have you as an active member), otherwise the carrying community where you hold the highest level, otherwise null for a public/standalone enrollment. A null enrolling community means no level context (gated modules stay locked) and no XP.

3. Per-community pricing — course_pricing.community_id. A mentor adds pricing rows (§17) scoped to any community they own. Pricing resolves in the community's context: browsing or enrolling through a community quotes that community's price, not the best price the member holds elsewhere. A course free in community A and $9 in community B costs a member of both nothing in A and $9 in B.

Within a single community, precedence is specific_communitycommunity_memberpublic (ties broken by the cheapest option). Non-members, anonymous callers, and contexts with no community always resolve to the public price.

Community deletion. Deleting a community unlinks its courses. A community_only course left carried by nothing would be visible to nobody, so it is automatically downgraded to unlisted — still hidden from search, still reachable by direct link. Courses carried by another community are unaffected.

See the Community API doc's "Course gating" section for the membership/level/XP mechanics from the community side.

Reviews

Requires a Sanctum bearer token. {course} takes a course UUID or the public slug.

31. Course reviews

GET    /api/v1/courses/{course}/reviews        → paginated list + my_review + summary
POST   /api/v1/courses/{course}/reviews        → write or revise the caller's review
DELETE /api/v1/courses/{course}/reviews/me     → remove the caller's review

Review object

{
  "id": "…",
  "course_id": "9b6f…",
  "rating": 5,
  "body": "Excellent.",
  "is_mine": false,
  "author": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" },
  "created_at": "2026-07-28T10:12:00+00:00",
  "updated_at": "2026-07-28T10:12:00+00:00"
}

GET 200

{
  "data": {
    "reviews":   { "data": [ { "…review…" } ], "links": { … }, "meta": { … } },
    "my_review": { "…review…" },
    "summary": {
      "average_rating": "4.60",
      "rating_count": 5,
      "rating_breakdown": { "5": 3, "4": 2, "3": 0, "2": 0, "1": 0 }
    }
  }
}
  • my_review is the caller's own review hoisted out of the list so the form can prefill for editing. null when they have not written one — not an error.
  • is_mine is on every row; the client hides the edit affordance by it.
  • summary rides along so the reviews tab needs one call rather than two.

POST body: { "rating": 1–5, "body"?: string }

Only an enrolled learner may post (403 otherwise), and posting twice updates the existing review rather than creating a second one — one review per user per course, enforced by a unique key. Returns 201 on create and 200 on update.

Every write recomputes average_rating, rating_count and rating_breakdown on the course inside the same transaction, so the summary can never disagree with the reviews below it.

The course author is notified (course.reviewed) on the first review only — an edit is the same learner revising the same opinion.

Errors: 403 not enrolled · 404 course not found / no review to delete · 422 rating outside 1–5


Authors

32. Authors

GET    /api/v1/courses/{course}/authors                    ← anyone reading the course page
GET    /api/v1/mentor/courses/{course}/authors             ← owner
POST   /api/v1/mentor/courses/{course}/authors             ← owner
DELETE /api/v1/mentor/courses/{course}/authors/{author}    ← owner

200

{
  "data": {
    "authors": [
      {
        "id": null,
        "role": "owner",
        "role_label": "Course author",
        "mentor_id": "8a1c…",
        "user_id": "4f2b…",
        "username": "janedoe",
        "name": "Jane Doe",
        "headline": "Product leader",
        "bio": "…",
        "avatar_url": null,
        "average_rating": "4.72",
        "reviews_count": 41,
        "students_count": 380,
        "courses_count": 6
      },
      { "id": "…", "role": "co_author", "role_label": "Co-author", "mentor_id": null, "…": "…" }
    ]
  }
}

The owner is synthesised, never stored. It is courses.mentor_id, placed at the head of the listing, which is what guarantees exactly one owner and stops the credit list from drifting out of agreement with the course. Its id is null — that credit is not a row and cannot be revoked. Credited rows carry mentor_id: null, since a co-author need not be a mentor at all.

POST body: { user_id | username, role } where role is co_author or assistant. owner is rejected with a 422 — the owner is derived and cannot be assigned.

⚠️ Credit only. A credited author appears on the Authors tab and gains no authoring rights: they cannot edit the course, its modules, its lessons or its pricing. Those stay with courses.mentor_id throughout the mentor API. Widening that means rewriting the ownership check on every mentor course route, which is a separate change.

Errors: 404 course / author not found · 422 already credited, already the owner, or role: owner


The management rail

Nine sidebar screens on the course page, addressed as ?tab=settings&section=…. Two conventions apply to all of them.

Owner routes and learner routes are separate. Where both sides need the same data:

  • /mentor/courses/{course}/… — the author's view, with drafts and everyone's rows
  • /courses/{course}/… — the enrolled learner's view, scoped to what they may see

Everything is paginated. Every list returns the Laravel envelope with meta.current_page / meta.last_page, even where today's screen renders one page.

Learner routes resolve through a participation check — owner or enrolled with access. A guest reading the sales page reaches none of it, and gets 403.

33. Students

GET    /api/v1/mentor/courses/{course}/students
PATCH  /api/v1/mentor/courses/{course}/students/{enrollment}    → { "status": "cancelled" }
DELETE /api/v1/mentor/courses/{course}/students/{enrollment}

Query: search (name / username), status, page, per_page.

{
  "id": "…",
  "course_id": "9b6f…",
  "user_id": "…",
  "status": "active",
  "status_label": "Active",
  "progress_pct": 68,
  "completed_lessons_count": 17,
  "enrolled_at": "2026-06-14T09:20:00+00:00",
  "last_accessed_at": "2026-08-17T18:05:00+00:00",
  "is_online": true,
  "can_chat": true,
  "can_view_profile": true,
  "user": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" }
}

id is the enrolment id, not a user id — it is what the two moderation routes address.

  • The roster never shows pending_payment: they still owe money and are not a student yet.
  • status filters on active · completed · cancelled · expired.
  • PATCH accepts those same four. pending_payment and refunded are not assignable — the payment flow owns them, and hand-writing one would desynchronise the enrolment from the money.
  • Revoking access decrements enrollment_count, floored at zero.

⚠️ can_chat / can_view_profile are not CommunityMember's logic, though the contract is identical. Those read chat_access / profile_visibility, per-membership columns a course enrolment has no equivalent of. Here they resolve the global block relationship, one query for the page. Absent means allowed, same as the members list. is_online reads last_accessed_at against the same window, which for a course means "active in this course recently" rather than "signed in somewhere".

34. Coursework — submissions

GET /api/v1/mentor/courses/{course}/submissions        ← the grading queue
GET /api/v1/courses/{course}/submissions/me            ← the learner's own work

One endpoint feeds both rail screens: "Assignments" is type=assignment and "Quizzes" is type=quiz. Both render submissions, not definitions.

Query: type (assignment | quiz), status, search (learner name), page, per_page.

{
  "id": "…",
  "type": "quiz",
  "type_label": "Quiz",
  "enrollment_id": "…",
  "lesson_id": "…",
  "lesson_title": "Array methods quiz",
  "module_id": "…",
  "module_title": "Arrays",
  "status": "submitted",
  "status_label": "Submitted",
  "learner": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" },
  "submitted_at": "2026-04-17T13:05:00+00:00",
  "is_late": false,
  "awarded_marks": null,
  "total_marks": 10,
  "quiz_score_pct": null,
  "graded_at": null,
  "feedback": null,
  "note": null,
  "file_url": null
}

This is the submission object widened with type, type_label, lesson_title, module_id, module_title and total_marks. learner.email is gone, replaced by avatar_url — the table renders an avatar and never rendered the address.

⚠️ status here is a presented value. The stored LessonProgressStatus is in_progress · submitted · completed; graded and late are derived from graded_at and is_late. The four-value vocabulary — pending · submitted · graded · late — works on both read and filter without widening the stored enum.

An untouched in_progress row is not a submission and never joins the queue. total_marks comes from assignment_payload.total_marks, quiz_payload.total_marks, or the question count.

Grading stays where it was — §16, nested under module and lesson.

35. Notices

Short announcements pinned above the course. Plain text, authored by the owner, read by everyone enrolled.

GET    /api/v1/mentor/courses/{course}/notices
POST   /api/v1/mentor/courses/{course}/notices           → { body, is_pinned? }
PUT    /api/v1/mentor/courses/{course}/notices/{notice}
DELETE /api/v1/mentor/courses/{course}/notices/{notice}

GET    /api/v1/courses/{course}/notices                  ← enrolled learner, read-only
{
  "id": "…",
  "course_id": "9b6f…",
  "body": "Week 3 live session moves to Thursday 18:00 UTC.",
  "is_pinned": true,
  "comments_count": 4,
  "author": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" },
  "created_at": "2026-08-12T09:00:00+00:00",
  "updated_at": "2026-08-12T09:00:00+00:00"
}

Ordered pinned first, then created_at descending.

Every announcement is a thread

An announcement saying "Thursday's session moves to 18:00" invites questions, so each one owns a thread in the course discussion: a comment carries the notice_id of the announcement it answers, and GET /courses/{course}/comments?notice_id={id} reads just that thread. No second comment system — the same rows, one more column.

comments_count is the size of that thread, so a board of twenty announcements renders "4 replies" on each without twenty requests.

Deleting an announcement deletes its thread. A queue of questions about an announcement has nothing left to be about once it is gone. Reactions and mention rows on those comments go with them.

body is plain text, not Lexical. The editor is a bare textarea and the list renders with whitespace-pre-wrap, so Lexical JSON written here would display as literal JSON.

Publishing a notice fans out course.notice_published to everyone enrolled — see the Notification System. The fan-out is chunked, because a popular course's roster is not a page.

36. Materials

Files, links and recordings attached to the course rather than to one lesson — the reference shelf a learner keeps coming back to.

GET    /api/v1/mentor/courses/{course}/materials
POST   /api/v1/mentor/courses/{course}/materials
PATCH  /api/v1/mentor/courses/{course}/materials/{material}
DELETE /api/v1/mentor/courses/{course}/materials/{material}

GET    /api/v1/courses/{course}/materials                ← enrolled learner, same resource
{
  "id": "…",
  "course_id": "9b6f…",
  "type": "video",
  "type_label": "Video",
  "title": "Introduction to React",
  "description": null,
  "url": "https://…",
  "thumbnail_url": "https://…",
  "has_poster": true,
  "duration_seconds": 754,
  "size_bytes": 48210432,
  "mime_type": "video/mp4",
  "position": 1,
  "created_at": "2026-07-02T10:00:00+00:00",
  "updated_at": "2026-07-02T10:00:00+00:00"
}
Field Notes
type video · image · file · link. The row's icon and layout switch on it
url The external URL for a link; the stored file's URL for everything else. The split is hidden here so the client renders one row shape
thumbnail_url The author's poster if they uploaded one, otherwise an image material's own file. null elsewhere, and the row falls back to a type icon
has_poster Whether a poster was uploaded. Lets Settings say "Replace poster" rather than "Add poster" without inferring it from thumbnail_url, which an image material populates from its own file
duration_seconds video only
size_bytes / mime_type Stored files only; null for links
position Author-defined order. Sorted by it, created_at descending as the tiebreak

POST — two body shapes, one per source:

{ "type": "link",  "title": "Official documentation", "url": "https://react.dev" }
{ "type": "video", "title": "Lesson recording",       "upload_id": "…" }

Poster frames

A materials list of recordings is a column of identical play icons without one. Posters are author-supplied, not extracted — this application has no ffmpeg, and adding one to draw a single still would be a large dependency for a small picture.

Send an optional poster_upload_id on create or update, from the same chunked flow using the course_material_image purpose — whatever the material's own type is, since a poster is an image even when the thing it previews is a 2 GB recording.

{ "type": "video", "title": "Lesson recording", "upload_id": "…", "poster_upload_id": "…" }

Accepted for every type, links included: a link to a recorded talk is helped by a still exactly as much as an uploaded one.

On PATCH you send What happens
key absent the poster is left alone
poster_upload_id: "…" the poster is replaced
poster_upload_id: null the poster is removed; the row falls back to a type icon

Unlike the material's file, the poster is mutable — it is presentation, not content, so an author can add or change one without re-uploading a 2 GB recording.

A thumb conversion (480×270, cropped) is generated on upload. Conversions are queued, so thumbnail_url serves the original in the short window before it lands rather than null — the row is never left without a picture it could have shown.

If frame extraction ever arrives for another reason, it populates this same collection and nothing on the wire changes.

There is no multipart path, on purpose. upload_id comes from the existing chunked upload flow, using the course_material_video / course_material_image / course_material_file purpose that matches type — so a session opened for a PDF can never be spent as a lecture recording. A 2 GB recording cannot go through a plain form post, and a second upload path would have to be maintained forever.

PATCH takes title, description and position only. A material's file is immutable — replacing bytes means delete and re-add, so a link someone saved never silently becomes a different file.

37. Notes

Private study notes — many per learner per course, each optionally anchored to a chapter, a lesson, or a moment in a lesson's video. A note with no anchors is a general course note. Nobody else, the course author included, can read them.

GET    /api/v1/courses/{course}/notes             → { "notes": [ … ] }
POST   /api/v1/courses/{course}/notes             → 201 { "note": {…} }
PATCH  /api/v1/courses/{course}/notes/{note}      → 200 { "note": {…} }
DELETE /api/v1/courses/{course}/notes/{note}      → 204
{ "data": { "note": {
  "id": "…",
  "course_id": "9b6f…",
  "body": "{\"root\":{\"children\":[…]}}",
  "module_id": "…", "module_title": "Reliability",
  "lesson_id": "…", "lesson_title": "Retries and backoff",
  "lesson_type": "video", "lesson_type_label": "Video",
  "timestamp_seconds": 214,
  "created_at": "2026-08-15T14:22:00+00:00",
  "updated_at": "2026-08-15T14:22:00+00:00"
} } }
  • body is Lexical JSON serialized as a string, stored as longText — never a length-capped varchar. There is no title — the list heads each note with its own first line, so there is nothing to keep in sync.
  • All five anchor fields are always present, null when unset. A client should not have to tell an absent key from an explicit null to know a note is unanchored.
  • The chapter is derived from the lesson. Send only lesson_id and the note comes back carrying module_id and module_title too — which is what makes ?module_id= pick up the lesson notes underneath it without the client sending both.
  • The titles outlive the ids. Deleting a lesson does not delete the notes on it: the foreign keys null out and the denormalised lesson_title / module_title / lesson_type survive, so the learner keeps what they wrote and it still routes to the right player.

Filters on the list: search, module_id, lesson_id, sort.

  • search reads a plain-text shadow column written on save, not the Lexical JSON — so searching paragraph, a word in every Lexical document ever written, matches nothing.
  • sort=lesson orders by module position → lesson position → timestamp. Unanchored notes sort last — they belong to the course rather than to any point in it.
  • The list is not paginated. This is one learner's notes on one course, which the filters narrow better than pages would.

Errors: 422 an anchor pointing at a lesson or chapter in a different course, or a timestamp_seconds with no lesson_id.

Deprecated — the singular routes. GET/PUT /courses/{course}/notes/me still answer. They read and upsert the caller's most recent note rather than 404ing, so a client that has not moved to the collection keeps working. Rows written under the old one-per-course unique index are now first-class members of the collection, as general course notes with no anchors, and their search shadow was backfilled — they are findable without needing a resave.

Privacy is the whole feature. The user is taken from the token and never from the route, so there is no address at which someone else's note exists — the course owner hitting this route gets their own note. These appear in no owner-facing export, no students endpoint and no admin listing, and CourseNoteService deliberately exposes no method that would enable one.

38. Comments — the course discussion

This is the third instance of one contract, not a third contract. The feed's comment object and Threading is unlimited; display is not specify the model in full: unlimited stored depth, depth / root_id / parent_author / mentions on every comment, replies as a bounded flat preview, replies_count as the total descendant count, author.is_me at every depth. The resource here is field-for-field identical apart from course_id replacing post_id, plus one course-only field — notice_id, which scopes a comment to an announcement's thread.

GET    /api/v1/courses/{course}/comments?notice_id={notice}
POST   /api/v1/courses/{course}/comments                        → { body, notice_id?, parent_id?, mentioned_user_ids? }
GET    /api/v1/courses/{course}/comments/{comment}/replies
POST   /api/v1/courses/{course}/comments/{comment}/reactions    → { type? }  (default like)
DELETE /api/v1/courses/{course}/comments/{comment}
POST   /api/v1/courses/{course}/comments/{comment}/report       → { reason, details? }

Scoped by announcement

Every announcement owns its own thread, and the flat course-wide discussion still exists underneath it.

notice_id on the comment The announcement this comment answers. null is the course-wide discussion — still served, still postable
?notice_id={id} on the read Just that announcement's thread. Absent, behaviour is unchanged: the whole discussion, every thread included
notice_id on the write Files the comment under that announcement

A notice_id belonging to another course is a 422, on both the read and the write — never a silent drop, which would file the comment on the course-wide discussion where the learner who asked will never see it again.

A reply inherits its parent's thread. Send parent_id alone and the reply lands in the same announcement's thread; a notice_id that disagrees with the parent's is a 422 rather than a comment filed under one announcement while answering another.

Who may post: enrolled learners and the author. A guest sees nothing — the discussion is not part of the public course payload, and every route resolves through the participation check.

Deletion: the comment's author, or the course owner, the same way a community owner can moderate their own space. Deleting takes the whole subtree with it — a reply has no meaning without the comment it answers. author.is_me decides the author's own delete affordance; the owner's comes from viewerRole === "owner".

Deleting an announcement deletes its whole thread the same way, reactions and mention rows included — see §35.

Reactions ride on the same polymorphic table the feed and community comments use, so the like button behaves identically on all three. Posting the type you already hold removes it, which is what makes it a toggle.

Mentions resolve against enrolled learners and the author only — mentioning someone who cannot read the thread would notify them into a room they cannot enter. mentioned_user_ids is a hint from the picker; the body is the source of truth and can neither add nor remove a mention.

Notifications: course.comment_mentioned takes precedence over course.commented, so one recipient gets one notification.

A comment is always resolved scoped to its course. Addressing a comment id from one course through another course's route is a 404 — that is what stops one course's access check being bypassed through another's.


Deferred / cross-phase (not yet enforced)

Item Column / enum present Unblocks with
Premium-exclusive courses courses.is_exclusive_to_premium Phase 7 — Platform Subscriptions
Enrollment expiry EnrollmentStatus::Expired Time-limited access — the status exists so clients can filter on it, but nothing sets it
Co-authors with authoring rights course_authors.role Widening the ownership check across every mentor course route (§32)
Automatic video poster extraction author-supplied posters ship (§36) An ffmpeg dependency; the author-supplied poster covers the case meanwhile and the wire shape would not change
cta_url on the storefront Deliberately dropped. A buy button pointing off-platform takes payment outside the platform's settlement path. cta_label ships and renders over the normal checkout
Feed / community comments on the shared threading core AbstractThreadedCommentService Courses is built on it; the two shipped surfaces still carry their own copies and are migrated separately
@ mention autocomplete on course comments mentions themselves ship (§38) Never built and not asked for. The body is the source of truth, so a picker is a convenience: the feed and community have one, courses do not

Decisions taken rather than asked about, recorded so they are easy to overrule:

  • per_user_limit on a coupon defaults to unlimited, not 1 (§18b).
  • Community coupons are not redeemable at join time. The validate-and-redeem mechanism is polymorphic and shared; wiring it is a coupon_code on the community join call — see the Community API deferred list.