REST API
A plain HTTP API over your course data — courses, modules, lessons, students, settings and translations. If you or your developer want to build something the app does not do (a sync script, an internal dashboard, your own agent), this is the door.
It uses the same key as an AI assistant does. There is no second credential to create: the key from Settings > Developers is the key you send here, and the permissions you ticked on it decide which endpoints answer.
Base URL and header
https://luna-online-course.firebaseapp.com/rest_api/v2Every request carries the key as a bearer token, and every request with a body is JSON:
Authorization: Bearer mcp_your-key-here
Content-Type: application/jsonDon't have a key yet? Create one in Settings > Developers — see MCP Keys. The value is shown once, right after you create it.
The key is a secret in a request header, so it belongs on a server, never in a browser or a mobile app you ship. Anyone holding it has exactly the permissions you ticked.
An OAuth access token works here too, and is treated exactly the same way — it carries the same permissions, and everything on this page applies to it unchanged.
Your first call
GET /whoami works with any valid key and needs no permission at all, which makes it the fastest
way to prove your setup works:
curl -s https://luna-online-course.firebaseapp.com/rest_api/v2/whoami \
-H "Authorization: Bearer $COURSE_KEY"{
"success": true,
"data": {
"apiVersion": "2.0.0",
"shopId": "shop_123",
"shopDomain": "example.myshopify.com",
"keyId": "k_456",
"keyName": "Reporting script",
"read": ["dashboard", "course", "student", "setting"],
"actions": ["course.create", "course.edit"],
"expiresAt": "2026-11-18T00:00:00.000Z",
"active": true,
"fieldGroups": {"course": {"course.edit": ["title", "description"]}},
"fieldTypes": {"student": {"email": "string", "courseIds": "string[]"}}
}
}Why /whoami matters
It is the discovery call. Instead of hardcoding what your integration may do, ask:
readandactions— the permissions this key holds. Check them before offering a feature, and you can tell a user "this key can't do that" instead of showing them a 403.fieldGroups— which fields each permission may write, per resource, served straight from the server's own lists. Build your forms and validation from this and they cannot drift out of step with the API.fieldTypes— the declared type of those fields (string,boolean,string[]). Advisory: it tells your client what to build, but the server's own value guards are what enforce it. A resource missing from the map carries no type constraint at all.expiresAtandactive— when the key runs out, and whether it is still usable.expiresAtisnullfor a key created with no expiration, and for an OAuth session, whose lifetime is the token's own.apiVersion— read it rather than hardcoding a version.
The envelope
Every answer has the same shape, success or failure.
Success
{"success": true, "data": {"id": "c1", "title": "Intro to Ceramics"}}Success on a list — lists, and only lists, also carry meta:
{
"success": true,
"data": [{"id": "c1"}],
"meta": {"count": 1, "total": 250, "pagination": {"hasNext": true}}
}count is how many rows came back, total how many matched (only where the service counted them),
pagination carries hasNext, hasPre, totalPage and sometimes note.
Failure
{
"success": false,
"error": "Course still has students",
"code": "COURSE_HAS_STUDENTS",
"details": {"studentCount": 3}
}Branch on code, never on error. code is a stable machine string and is present on every
failure; error is a sentence written for a human or a model to read and may be reworded.
details appears only when there was something actionable to add — don't require it.
The 27 endpoints
Permission is the scope the key must hold. Read scopes are plain words (course,
student, setting, dashboard); action scopes always contain a dot, and that dot is the
whole difference — holding course does not open course.edit.
A call the key has no permission for answers 403 SCOPE_DENIED, naming the scope it needed.
Identity
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /whoami | any valid key | What this key is and what it may do — see above |
Dashboard
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /dashboard/stats | dashboard | Course, student and enrollment counts, completion rate, top courses |
Courses
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /courses | course | List courses — searchText, status, limit, after |
| GET | /courses/:id | course | One course with its modules, lessons, certificate and thumbnail |
| POST | /courses | course.create | Create a course — title required |
| PUT | /courses/:id | any of course.edit, course.manage_certificate | Update a course's fields and/or its certificate map |
| DELETE | /courses/:id | course.delete | Delete a course — refused while it has students |
Modules
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /courses/:courseId/modules | course | Modules of a course, in display order — limit, offset |
| POST | /courses/:courseId/modules | course.create_module | Create a module — title |
| PUT | /modules/:id | course.create_module | Rename a module |
| POST | /courses/:courseId/modules/reorder | course.create_module | Reorder — orderedIds, every module id in the new order |
| DELETE | /modules/:id | course.delete_content | Delete a module |
Lessons
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /courses/:courseId/lessons | course | Every lesson of a course — limit |
| GET | /lessons/:lessonId | course | One lesson with its contents and quiz |
| POST | /modules/:moduleId/lessons | course.edit_lessons | Create a lesson — title, description, status, isFreePreview, contents, quiz |
| PUT | /lessons/:lessonId | course.edit_lessons | Update a lesson — the same fields |
| POST | /modules/:moduleId/lessons/reorder | course.edit_lessons | Reorder — orderedIds, every lesson id in the new order |
| DELETE | /lessons/:lessonId | course.delete_content | Delete a lesson |
Students
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /students | student | List students — searchText, limit, after |
| GET | /students/:id | student | One student with enrollments and progress |
| POST | /students | student.create | Create a student and enroll them — email required, courseIds must be an array |
Student endpoints return personal data — names, emails, enrollment records, lesson progress. Give
a key student access only when the integration genuinely needs it.
Settings
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /settings | setting | The published settings groups: general, appearance, email, studentAccess, translation |
| PUT | /settings | any of setting.edit_general, setting.edit_appearance, setting.edit_notifications | Update settings, as flat dot-paths |
Translations
| Method | Path | Permission | What it does |
|---|---|---|---|
| GET | /translations | setting | One locale with code, every locale without it |
| PUT | /translations | setting.edit_translation | Patch locales — defaultLocale, publish, values |
Notification emails
Part of Settings, so both take setting.edit_notifications.
| Method | Path | Permission | What it does |
|---|---|---|---|
| POST | /notifications/test-email | setting.edit_notifications | Send a test email — templateKey |
| PUT | /notification-templates/:id | setting.edit_notifications | Update a template — subject, headerTitle, contentHtml, replyTo, enabled |
Reading data
curl -s "https://luna-online-course.firebaseapp.com/rest_api/v2/courses?limit=5&status=published" \
-H "Authorization: Bearer $COURSE_KEY"Two things to know before you branch on a response:
- Only three reads answer 404 when a record is missing:
GET /courses/:id,GET /lessons/:lessonIdandGET /students/:id. Nowhere else does a 404 mean "bad id". - A list with a wrong parent id answers 200 with an empty array.
GET /courses/unknown-id/modulesmeans "no modules", not "no course". LikewiseGET /translations?code=xx-YYanswers 200 for a locale that does not exist, withfound: falsein the payload — that flag is the only signal a wrong code gives.
Writing data
curl -s -X POST https://luna-online-course.firebaseapp.com/rest_api/v2/courses \
-H "Authorization: Bearer $COURSE_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Intro to Ceramics", "status": "draft"}'Rules that are easy to trip over:
- Settings are flat dot-paths, one per writable path — the server rebuilds the nested object:
{"appearance.primaryColor": "#1a73e8", "email.enrollmentEmail": false}. The writable paths aregeneral.appDisplayName;appearance.portalLogoUrl,appearance.primaryColor,appearance.myCoursesPageTitle,appearance.startLearningButton,appearance.continueButton,appearance.completedBadge;email.senderName,email.replyToEmail,email.enrollmentEmail,email.completionEmail. - Reordering is a POST on a sub-resource, and every DELETE takes its id from the path — a body on a DELETE is not read.
courseIdsmust be an array, even for a single course."courseIds": "c1"is a 400INVALID_INPUT.- A required field left out is a 400
INVALID_INPUT, and a blank string counts as left out.POST /studentsneedsemail;POST /notifications/test-emailneedstemplateKey. AtemplateKeythat is well-formed but does not exist is a 404NOT_FOUND, with no mail sent. thumbnailUrlmust be anhttpsURL. There is no file upload over this API.POST /coursesnever acceptscertificate, whatever the key holds — it always comes back ignored. Set it afterwards withPUT /courses/:id, which is wherecourse.manage_certificateapplies.POST /notifications/test-emailtakes no recipient. It goes to the shop's own contact address; any recipient you send is reported as ignored.- Adding or removing a locale is not available over the API.
PUT /translationspatches the locales you send and leaves the rest alone.
ignoredFields is not an error
Send a field the key may not write — or one the endpoint does not accept — and the request still succeeds. The field is dropped and named back to you:
{
"success": true,
"data": {
"updated": true,
"data": {"id": "c1"},
"ignoredFields": ["certificate"],
"note": "Fields outside this permission were ignored."
}
}When every field you sent was out of scope, nothing is written and updated is false, with a
note saying so.
Read updated before you tell anyone a change landed. A 200 on its own does not mean anything
was written.
Pagination
This is the part integrations most often get wrong. There are three different behaviours, and which one you get depends on the endpoint.
Normal lists: page with after
GET /courses and GET /students are the two cursor-paged lists.
limit— how many rows, capped at 100, default 20 on these two lists. A value that isn't a whole number is ignored rather than guessed at:?limit=10abcdoes not become 10.after— the id of the last row of the page you just read. Leave it out for page one.
Read meta.pagination.hasNext. While it is true, take the id of the last row and send it as
?after= on the next call:
curl -s "https://luna-online-course.firebaseapp.com/rest_api/v2/students?limit=100&after=stu_9f2" \
-H "Authorization: Bearer $COURSE_KEY"| What you send | What you get |
|---|---|
?after= (empty) | Page one — not an error |
?after= with the last row's id | The next page |
?after= with a malformed id (a/b, .., over 1500 bytes) | 400 INVALID_INPUT |
?after= with a well-formed id that no longer exists | 400 CURSOR_NOT_FOUND — restart from page one |
There is no before: paging backwards is not offered.
Search cannot be paged
searchText and after cannot be combined. Sending both is a 400 SEARCH_NOT_PAGEABLE.
Search is ranked on a different backend from the cursor, so honouring both would hand you page one forever. Instead, a search returns the best matches it can fit in one page:
meta.total— how many rows actually matched;meta.count— how many came back;meta.pagination.hasNext— alwaysfalseon a search;meta.pagination.note— present when rows were left behind, with the server's own sentence explaining it.
So when total is larger than count, do not retry with after. Either narrow searchText,
or raise limit (max 100). If your client shows its own pagination advice, let note win over it.
If the search backend itself is down you get 503 SEARCH_UNAVAILABLE — never an empty list, so
"no results" always means no results.
Two lists are not paged at all
GET /courses/:courseId/modules and GET /courses/:courseId/lessons behave differently from
everything above:
- Send no
limitand you get every row — not 20, not 100. The cap of 100 only applies to alimityou actually sent (?limit=500becomes 100). - Neither ever carries
meta.pagination. There is nohasNextto read, onlymeta.count. /lessonstakes nooffset— onlylimit. It can be truncated, not paged, and nothing tells you how much was left behind. Ask for everything, or accept the cut./modulestakesoffset(capped at 1000) and that is the only way forward. Since there is nohasNext, page until a call returns fewer rows than thelimityou asked for.
visibility is not a filter
?visibility=private on GET /courses is not applied — it used to be advertised while nothing
applied it, so it was removed rather than left to mislead. visibility is still on every row that
comes back: filter on your side.
Rate limiting
60 requests per minute per key. Over the limit you get 429 RATE_LIMITED with a
Retry-After header in seconds.
Read the number in that header and wait it out — don't invent your own backoff. The header has been
on every 429 since 2.0.0 and will stay.
This is a runaway-client guard, not a billing control. Authentication failures are counted per IP as well, so a script hammering with a bad key is throttled before it reaches a route.
Error codes
Branch on code. This is all of them.
| Code | Status | What happened | What to do |
|---|---|---|---|
AUTH_FAILED | 401 | Missing or malformed header, unknown key, invalid OAuth token | Fix the credential; don't retry as-is |
AUTH_FAILED | 500 | Authentication itself failed internally | Retry with backoff |
KEY_EXPIRED | 401 | The key expired or was deactivated | Create a new key in Settings > Developers |
SCOPE_DENIED | 403 | The key lacks the permission this endpoint needs | Tick that permission on the key |
FEATURE_LOCKED | 403 | A paid feature (certificate, quiz, translation caps) is not unlocked | Tell the merchant — the feature has to be unlocked in the app; don't retry |
SHOP_NOT_FOUND | 404 | The key's shop no longer exists | Stop |
NOT_FOUND | 404 | Unknown path, or the record does not exist | Check the id or the path |
METHOD_NOT_ALLOWED | 405 | The path exists, the method does not | Use the documented method |
NOT_IMPLEMENTED | 501 | The method is not one this API implements | Use the documented method |
RATE_LIMITED | 429 | Quota exhausted | Wait the Retry-After seconds |
INVALID_INPUT | 400 | Validation failed — a bad value, a malformed cursor, a wrong type | Fix the arguments |
CURSOR_NOT_FOUND | 400 | after is a well-formed id that no longer exists | Restart the list from page one |
SEARCH_NOT_PAGEABLE | 400 | searchText was sent together with after | Send searchText alone, or drop it and page with after |
SEARCH_UNAVAILABLE | 503 | The search backend did not answer | Retry shortly, or list without searchText |
COURSE_HAS_STUDENTS | 400 | Delete refused; details.studentCount says how many | Unenroll first, or keep the course |
INTERNAL_ERROR | 500 | Something unplanned; error is always Internal server error | Retry with backoff, then report it |
An unknown path answers a JSON body too, never an empty 404 — so you can always tell "wrong URL" from "wrong key".
Related
- MCP Keys — create the key, choose its permissions, regenerate or revoke it.
- What each permission unlocks — what each permission on that key actually allows.
- MCP Desktop Setup — same key, for assistants that run a local server instead of calling this API themselves.