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; and a mentee enrolling and learning with progress tracking.
- 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 usemultipart/form-data)
Scope note. Real payment settlement is deferred (no gateway exists yet, mirroring the booking checkout): paid enrollment records a
price_snapshotand grants access immediately;payment_idstays null until the payments module lands. Community linkage is now live (the community module activates it): 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).
⚠️ 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) |
Lesson type: video · text · pdf · quiz · assignment
(quiz auto-scores; assignment is a mentor-graded file submission)
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: active · completed · refunded · cancelled
(active and completed grant content access)
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"],
"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": "0.00",
"thumbnail_url": 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 nestedlessons) is included only on the show response.total_lessons/total_duration_minutesare recomputed from published lessons.
Module object
{
"id": "1a2b…",
"course_id": "9b6f…",
"title": "Foundations",
"description": null,
"position": 0,
"unlock_at_level": null,
"is_published": true,
"lessons": [ { "…lesson object…" } ],
"created_at": "…",
"updated_at": "…"
}
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": [ … ] },
"assignment_payload": null,
"is_preview": false,
"is_published": true,
"attachment_url": null,
"assignment_attachments": [],
"created_at": "…",
"updated_at": "…"
}
time_limit_minutes(optional) applies toquizandassignmentlessons — see the timer notes.assignment_payloadholds{ total_marks, passing_marks, instructions };assignment_attachmentsis the array of mentor-provided brief files ([{ id, name, url }]).
Pricing object
{
"id": "7d2e…",
"course_id": "9b6f…",
"audience_type": "public",
"audience_type_label": "Public",
"community_id": null,
"is_free": false,
"price": "19.00",
"currency": "USD",
"created_at": "…",
"updated_at": "…"
}
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",
"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)…" }
}
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.
1. List my courses
GET /api/v1/mentor/courses
Returns the mentor's courses (all statuses), newest first, as a plain collection.
200 → { "data": { "courses": [ { "…course object…" } ] } }
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 behindis_creator/is_enrolledflags, 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 |
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 |
string | no | Default USD |
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/{id}
Returns the course with its modules → lessons eager-loaded (all statuses; authoring view).
200 → { "data": { "course": { "…course object with modules…" } } }
Errors: 404 not found / not owned
4. Update a course
PUT|PATCH /api/v1/mentor/courses/{id} (multipart/form-data if replacing the thumbnail)
Same fields as create, all optional (sometimes). Changing title regenerates the slug.
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/{id}/publish → status: published (sets published_at once)
PATCH /api/v1/mentor/courses/{id}/unpublish → status: draft
PATCH /api/v1/mentor/courses/{id}/archive → status: archived
200 → { "data": { "course": { … } } }
8. Delete a course
DELETE /api/v1/mentor/courses/{id}
Cascades to modules, lessons, pricing, enrollments, and progress.
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 | ≥ 1; locks the module until the learner reaches this level in the community they enrolled through. 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): video → video_url; text → text_content;
pdf → attachment; quiz → quiz_payload; assignment → assignment_payload.
Quiz payload shape
{
"pass_pct": 70,
"questions": [
{ "prompt": "2 + 2?", "options": ["3", "4", "5"], "correct": [1], "explanation": "…" }
]
}
correct is an array of the correct option indices (supports multi-select).
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": "sam@example.com" },
"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
Per-audience price overrides for a course. The (course, audience_type, community_id) triple
is unique. Overrides for all three audiences are consulted at enrollment — for a member, a
matching community price wins over the public price (see Community linkage).
17. List / create pricing
GET /api/v1/mentor/courses/{course}/pricing → { "data": { "pricing": [ { …pricing object… } ] } }
POST /api/v1/mentor/courses/{course}/pricing → 201 { "data": { "pricing": { … } } }
Create payload
| Field | Type | Required | Notes |
|---|---|---|---|
audience_type |
enum | yes | public · community_member · specific_community |
community_id |
uuid | conditionally | Forbidden for public; required for the community audiences — and must be a community you own |
is_free |
bool | no | |
price |
decimal | conditionally | Required unless is_free is true |
currency |
string | no | Default USD |
Errors: 422 public pricing with a community_id, a community audience without one, or a
community_id you don't own.
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." }
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.
Query parameters
| Param | Type | Notes |
|---|---|---|
search |
string | Matches title / subtitle / description |
is_free |
bool | Filter by free/paid |
mentor_id |
uuid | Courses by one mentor |
sort |
enum | newest (default) · popular · rating · price_low · price_high |
per_page |
int | 1–100 (default 15) |
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
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.
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.
200 (excerpt)
{
"data": {
"course": {
"id": "9b6f…", "title": "…", "slug": "…", "is_free": false, "price": "49.00",
"mentor": { "id": "8a1c…", "name": "Jane Doe", "headline": "…", "avatar_url": null },
"modules": [
{ "id": "1a2b…", "title": "Foundations",
"lessons": [
{ "id": "3c4d…", "title": "Preview", "lesson_type": "video", "locked": false,
"is_preview": true, "video_url": "https://…" },
{ "id": "4d5e…", "title": "Locked", "lesson_type": "video", "locked": true,
"is_preview": false }
] }
]
}
}
}
Errors: 404 not found / not directly accessible
Mentee endpoints — enrollment
All require a Sanctum token.
21. Resolve pricing for me
GET /api/v1/mentee/courses/{course}/pricing
Returns the effective price the current user would pay. Resolution: a public override (if the
mentor set one) beats the base price; then, for an active member of a community that prices
this course, a matching community price wins — precedence specific_community → community_member,
ties broken by the cheapest option. Non-members and anonymous callers resolve to the public price.
See Community linkage.
200 (a member whose community grants free access)
{
"data": { "pricing": { "audience_type": "community_member", "is_free": true, "price": null, "currency": "USD" } }
}
Errors: 404 course not found
22. Enroll
POST /api/v1/mentee/courses/{course}/enroll
Enrolls the user. Free (or free-for-audience) courses grant access immediately; paid
courses snapshot the resolved price (is_free: false, price_snapshot) and grant access —
real gateway settlement / payment_id is deferred. Increments the course enrollment_count.
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 |
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 → { "data": { "enrollment": { "…enrollment object…" } } }
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 |
| 404 | Course not found |
| 401 | Unauthenticated |
Active members of any community carrying a
community_onlycourse 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): status (enrollment status), per_page (default 15).
Errors (show): 404 not the caller's enrollment.
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
correctandexplanationkeys — answer keys are never sent to the client. Assignment lessons expose their brief (total_marks,passing_marks,instructions,attachments) but no grading internals.
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": "…", "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. 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/pdflessons complete via endpoint 26;quizlessons complete by passing endpoint 28 (or can be force-completed via 26).assignmentlessons goin_progress→submitted(learner uploads via endpoint 29) →completedwhen the mentor grades a pass (endpoint 16). A graded fail drops back toin_progressso 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;startisn't needed. - Quiz (hard cutoff) — the lesson must be started, and a submission after
started_at + time_limit_minutesis rejected with422. - Assignment (late allowed) — a late submission is accepted but flagged
is_late: trueand itsnotebecomes required (the reason for lateness). The mentor seesis_latewhen 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:
- Visibility —
community_onlycourses 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 gating —
unlock_at_levelis 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:
- Module locking — a module's
unlock_at_levellocks it until the learner reaches that level in the enrolling community. In learner content, locked modules are withheld. - XP —
lesson_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_community → community_member → public
(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.
Deferred / cross-phase (not yet enforced)
| Item | Column / enum present | Unblocks with |
|---|---|---|
| Premium-exclusive courses | courses.is_exclusive_to_premium |
Phase 7 — Platform Subscriptions |
| Real paid settlement / escrow | course_enrollments.payment_id |
Payments module |
| "New lesson published" notification | — | Phase 10 — Notifications |
Reviews / ratings (feeds average_rating) |
courses.average_rating |
Not yet built (standalone-buildable) |