name: lw-lms-rest-frontend
description: Build a custom frontend against LW LMS's headless /wp-json/lms/v1 REST API in lw-lms v1.5.1. Use for React, Vue, Astro, mobile, or theme code that calls /courses, /courses/{id}, /lessons/{id}, /progress, /progress/course/{id}, or /download/{id} and must handle public course content, lesson/access gating, paid-course products/subscriptions/subscription_variations/memberships, progress payloads, downloads, and nonce/app-password auth.
author: Soczó Kristóf
contact: mailto:lonsdale201@hotmail.com
plugin: lw-lms
plugin-version-tested: "1.5.1"
php-min: "8.1"
last-updated: "2026-06-15"
docs:
- https://github.com/lwplugins/lw-lms
source-refs:
- wp-content/plugins/lw-lms/includes/Api/RestApi.php
- wp-content/plugins/lw-lms/includes/Api/Controllers/CoursesController.php
- wp-content/plugins/lw-lms/includes/Api/Controllers/LessonsController.php
- wp-content/plugins/lw-lms/includes/Api/Controllers/ProgressController.php
- wp-content/plugins/lw-lms/includes/Api/Controllers/DownloadController.php
- wp-content/plugins/lw-lms/includes/Api/Transformers/CourseTransformer.php
- wp-content/plugins/lw-lms/includes/Api/Transformers/LessonTransformer.php
- wp-content/plugins/lw-lms/includes/Api/Transformers/ProgressTransformer.php
- wp-content/plugins/lw-lms/includes/Access/AccessChecker.php
- wp-content/plugins/lw-lms/includes/Access/MembershipChecker.php
- wp-content/plugins/lw-lms/includes/Access/SubscriptionVariationChecker.php
- wp-content/plugins/lw-lms/includes/Access/WooCommerceChecker.php
- wp-content/plugins/lw-lms/includes/Meta/VideoParser.php
- wp-content/plugins/lw-lms/includes/Progress/ProgressCalculator.php
- wp-content/plugins/lw-lms/CHANGELOG.md
LW LMS: REST frontend consumer
For frontend developers consuming LW LMS data: course catalog, course detail, lesson player, progress dashboard, and protected downloads. The core lw-lms plugin ships a headless REST API; it does not ship public templates, shortcodes, or blocks.
BETA NOTICE. The plugin README says the plugin is under active development and not recommended for production use. Snapshot the JSON shapes in tests and review
CHANGELOG.mdbefore upgrading. This skill is verified against local lw-lms v1.5.1.
Version deltas that matter
- v1.5.1: maintenance release, no functional REST changes.
- v1.5.0: paid-course denied
accesspayload can includemembershipswhen WooCommerce Memberships is active and the course has_lw_lms_membership_plan_ids. - v1.4.0: course
contentin/courses/{id}is public marketing/about content. It is no longer gated behindaccess.has_access. Lesson content is still gated by/lessons/{id}. - v1.4.0: open-course lessons remain accessible to guests even if marked as preview.
- v1.3.0: first logged-in access to a free course lazily writes a
source='free'access row server-side; the REST consumer does not need to do anything special. - v1.2.15: denied paid-course
accesspayload can includesubscription_variations.
Misconception this skill corrects
"I should use
course.contentas the access gate."
Wrong for v1.4.0+. CourseTransformer::transform_full() always includes the course content; this is the public course description. Use course.access.has_access and each lesson's accessible flag for gating.
// WRONG: course content is public and is not proof of access.
if (course.content) {
renderLessonPlayer();
}
// RIGHT: access comes from access.has_access and per-lesson accessible.
if (course.access.has_access) {
return <CourseContent html={course.content} progress={course.progress} />;
}
return <PurchaseGate access={course.access} />;
Lesson body content still comes from GET /lms/v1/lessons/{id} and returns 403 forbidden when AccessChecker::has_lesson_access() denies the request.
REST API surface
Namespace: lms/v1, mounted at /wp-json/lms/v1/....
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/lms/v1/courses |
public | Paginated public course list |
GET |
/lms/v1/courses/{id} |
public | Single course detail; course content is public, lesson rows carry accessible |
GET |
/lms/v1/lessons/{id} |
public route, access-gated body | Single lesson; 403 without lesson access |
GET |
/lms/v1/progress |
logged-in | Current user's all progress rows |
GET |
/lms/v1/progress/course/{id} |
logged-in | Current user's progress rows for one course plus course summary progress |
POST |
/lms/v1/progress |
logged-in + lesson access | Upsert lesson status |
GET |
/lms/v1/download/{id} |
public route, access-gated file | Stream protected attachment binary |
Course list
GET /wp-json/lms/v1/courses?per_page=12&page=1&category=php&level=beginner&search=oop
Parameters:
| Param | Default | Validation |
|---|---|---|
per_page |
10 |
integer 1..100 |
page |
1 |
integer >= 1 |
category |
'' |
course category slug |
level |
'' |
course level slug |
search |
'' |
text search |
List response shape:
{
"data": [
{
"id": 42,
"title": "PHP Object-Oriented Fundamentals",
"slug": "php-oop-fundamentals",
"excerpt": "Short summary.",
"thumbnail": "https://site.test/wp-content/uploads/course.jpg",
"categories": [{ "id": 5, "name": "PHP", "slug": "php" }],
"level": { "id": 9, "name": "Beginner", "slug": "beginner" },
"duration": "8h",
"lesson_count": 24,
"access": { "type": "paid", "has_access": false }
}
],
"meta": {
"total": 47,
"pages": 4,
"current_page": 1,
"per_page": 12
}
}
List items do not include products, subscriptions, subscription variations, memberships, expiry, sections, attachments, or progress. Fetch the single course for those.
Course detail
GET /wp-json/lms/v1/courses/42
Important fields:
| Field | Presence |
|---|---|
content |
Always present for published courses; public marketing/about description |
content_raw |
Editors only (current_user_can( 'edit_posts' )) |
sections |
Always present; each section contains lesson list rows |
lessons_without_section |
Always present; render after sections |
attachments |
Populated only when access.has_access === true, otherwise empty array |
progress |
Present for logged-in users |
access.expires_at |
Paid course with access row and non-empty expiry; MySQL datetime string |
access.products |
Denied paid course with linked WooCommerce products |
access.subscriptions |
Denied paid course with linked parent subscription products |
access.subscription_variations |
Denied paid course with linked subscription variation pairs |
access.memberships |
Denied paid course with linked WooCommerce Memberships plans |
Denied paid-course example:
{
"id": 42,
"title": "PHP Object-Oriented Fundamentals",
"content": "<p>Public course description.</p>",
"access": {
"type": "paid",
"has_access": false,
"requires": "purchase",
"products": [
{ "id": 200, "name": "PHP OOP Course", "price": "49.00", "price_formatted": "$49.00", "url": "...", "access_duration": 0 }
],
"subscriptions": [
{ "id": 201, "name": "All Access", "price": "19.00", "price_formatted": "$19.00 / month", "url": "..." }
],
"subscription_variations": [
{ "parent_id": 300, "variation_id": 305, "name": "All Access - Yearly", "attributes": {"attribute_plan": "yearly"}, "price": "190.00", "price_formatted": "$190.00 / year", "url": "..." }
],
"memberships": [
{ "id": 77, "name": "Pro Members", "join": "https://site.test/product/pro-membership/" }
]
},
"sections": [
{
"id": "sec_intro",
"title": "Getting Started",
"description": "",
"order": 0,
"lessons": [
{ "id": 100, "title": "What is OOP", "order": 0, "duration": "12 min", "preview": true, "accessible": true, "completed": false }
]
}
],
"lessons_without_section": [],
"attachments": [],
"progress": { "completed_lessons": 0, "total_lessons": 24, "percentage": 0 }
}
Render the complete outline from both sections[].lessons and lessons_without_section. Do not hide locked lessons; render them as locked/disabled so users can see the syllabus.
Lesson detail
GET /wp-json/lms/v1/lessons/100
The route is public, but the response is access-gated:
- Open-course lessons are available to guests.
- Free/paid course lessons require course access unless the lesson is marked preview.
- Preview lessons require a logged-in user in the current source.
- No access returns
403 forbidden. - Missing/non-published lessons return
404 not_found.
Lesson response shape:
{
"id": 100,
"title": "What is OOP",
"content": "<p>Lesson body.</p>",
"course": { "id": 42, "title": "PHP OOP Fundamentals" },
"section": { "id": "sec_intro", "title": "Getting Started" },
"order": 0,
"duration": "12 min",
"video": { "url": "https://www.youtube.com/watch?v=abc123", "provider": "youtube", "video_id": "abc123", "embed": "https://www.youtube.com/embed/abc123", "duration": "" },
"attachments": [
{ "id": 250, "title": "Slides.pdf", "filename": "slides.pdf", "mime_type": "application/pdf", "size": 245678, "download_url": "https://site.test/wp-json/lms/v1/download/250" }
],
"navigation": {
"previous": null,
"next": { "id": 101, "title": "Next lesson" }
}
}
video can be null; section, navigation.previous, and navigation.next can also be null. Switch video rendering on video.provider, not URL string matching.
Progress
Read all current-user progress:
GET /wp-json/lms/v1/progress
Read current-user progress for one course:
GET /wp-json/lms/v1/progress/course/42
The course-scoped response adds course_progress from ProgressCalculator::calculate():
{
"data": [
{
"user_id": 5,
"course_id": 42,
"lesson_id": 100,
"status": "completed",
"completed_at": "2026-04-15 10:30:00",
"created_at": "2026-04-14 09:00:00",
"updated_at": "2026-04-15 10:30:00"
}
],
"course_progress": {
"completed_lessons": 5,
"total_lessons": 24,
"percentage": 21
}
}
Write progress:
POST /wp-json/lms/v1/progress
Content-Type: application/json
{
"course_id": 42,
"lesson_id": 100,
"status": "completed"
}
Required fields are course_id, lesson_id, and status. Status must be not_started, in_progress, or completed.
The server validates:
- logged-in user, else
401 unauthorized; - lesson access, else
403 forbidden; - lesson belongs to the submitted course, else
400 invalid_request; - DB write, else
500 update_failed.
Success returns the saved progress row plus authoritative course_progress. Use the returned summary instead of recalculating client-side.
Downloads
download_url fields point at /lms/v1/download/{attachment_id} and return a binary stream, not JSON. Use an <a href> for normal downloads or fetch as blob() if you need client-side handling.
The download endpoint locates the attachment by scanning _lw_lms_attachments on course/lesson posts, applies course/lesson access, fires lw_lms_attachment_downloaded on success, and then sends file headers.
Authentication
| Context | Auth pattern |
|---|---|
| Public catalog and course detail | no auth |
| Logged-in browser UI | WordPress cookie + X-WP-Nonce, usually via wp.apiFetch |
| Headless/mobile | Application Password Basic auth, or a vetted JWT/OAuth layer |
| Downloads | same auth context as the protected course/lesson |
For browser fetch, include both nonce and credentials:
await fetch('/wp-json/lms/v1/progress', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce
},
body: JSON.stringify({ course_id: 42, lesson_id: 100, status: 'completed' })
});
Critical rules
- Course
contentis public in v1.5.1; never use it as the access gate. - Lesson detail and download endpoints are the protected surfaces.
- Iterate both
sectionsandlessons_without_section. - Render locked lessons disabled, not hidden.
access.membershipsis available only when WooCommerce Memberships functions exist and linked plans are configured.- List endpoint access info is intentionally small; build purchase gates from the single-course response.
course_progressusescompleted_lessons, notcompleted_count.completed_atisnullfor non-completed rows.content_rawis editor-only source content; do not render it in public UI.download_urlis already a full REST URL; do not reconstruct it by concatenating/wp-json.- The download endpoint returns binary data; do not call
response.json()on it.
Common mistakes
// WRONG: only iterating sections.
course.sections.map(section => <Section section={section} />);
// RIGHT: include orphan lessons too.
<>
{course.sections.map(section => <Section key={section.id} section={section} />)}
{course.lessons_without_section.length > 0 && (
<LessonGroup lessons={course.lessons_without_section} />
)}
</>
// WRONG: old pre-1.4 assumption.
const canAccess = Boolean(course.content);
// RIGHT.
const canAccess = course.access.has_access;
// WRONG: progress key from stale docs.
<ProgressBar value={data.course_progress.completed_count} />
// RIGHT.
<ProgressBar value={data.course_progress.completed_lessons} />
// WRONG: POST body missing course_id.
fetch('/wp-json/lms/v1/progress', {
method: 'POST',
body: JSON.stringify({ lesson_id: 100, status: 'completed' })
});
// RIGHT.
fetch('/wp-json/lms/v1/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpApiSettings.nonce },
credentials: 'same-origin',
body: JSON.stringify({ course_id: 42, lesson_id: 100, status: 'completed' })
});
Cross-references
- Use
lw-lms-backend-extendfor hooks, access/progress repositories, and companion-plugin backend integration. - Use
lw-lms-abilitiesfor admin/agentlw-lms/*Abilities API calls; those are not the learner-facing REST endpoints. - Use
lw-lms-wp-cli-operationsfor operational WP-CLI course, lesson, enrollment, revoke, and force-complete commands. - Use
lw-lms-learndash-migrationfor the one-time LearnDash migration command.
What this skill does NOT cover
- A separate frontend plugin. This skill documents the core
lw-lmsREST contract only. - Payment processing. Link to WooCommerce products/subscriptions/membership join URLs and let WooCommerce handle checkout.
- Custom REST route registration. Use normal WordPress
register_rest_route()patterns in a companion plugin. - Server-rendered theme templates. You can consume REST server-side, but direct CPT/meta queries are usually simpler in PHP templates.
References
- REST namespace and route registration:
includes/Api/RestApi.php. - Course list/detail routes:
includes/Api/Controllers/CoursesController.php. - Lesson route and 403 behavior:
includes/Api/Controllers/LessonsController.php. - Progress routes and cross-validation:
includes/Api/Controllers/ProgressController.php. - Download route and binary response:
includes/Api/Controllers/DownloadController.php. - Course response shape and public
content:includes/Api/Transformers/CourseTransformer.php. - Lesson response shape:
includes/Api/Transformers/LessonTransformer.php. - Progress row shape:
includes/Api/Transformers/ProgressTransformer.php. - Paid access payload including memberships:
includes/Access/AccessChecker.php,includes/Access/MembershipChecker.php.