Avada Online Course
Settings
REST API

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/v2

Every 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/json

Don'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:

  • read and actions — 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.
  • expiresAt and active — when the key runs out, and whether it is still usable. expiresAt is null for 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

MethodPathPermissionWhat it does
GET/whoamiany valid keyWhat this key is and what it may do — see above

Dashboard

MethodPathPermissionWhat it does
GET/dashboard/statsdashboardCourse, student and enrollment counts, completion rate, top courses

Courses

MethodPathPermissionWhat it does
GET/coursescourseList courses — searchText, status, limit, after
GET/courses/:idcourseOne course with its modules, lessons, certificate and thumbnail
POST/coursescourse.createCreate a course — title required
PUT/courses/:idany of course.edit, course.manage_certificateUpdate a course's fields and/or its certificate map
DELETE/courses/:idcourse.deleteDelete a course — refused while it has students

Modules

MethodPathPermissionWhat it does
GET/courses/:courseId/modulescourseModules of a course, in display order — limit, offset
POST/courses/:courseId/modulescourse.create_moduleCreate a module — title
PUT/modules/:idcourse.create_moduleRename a module
POST/courses/:courseId/modules/reordercourse.create_moduleReorder — orderedIds, every module id in the new order
DELETE/modules/:idcourse.delete_contentDelete a module

Lessons

MethodPathPermissionWhat it does
GET/courses/:courseId/lessonscourseEvery lesson of a course — limit
GET/lessons/:lessonIdcourseOne lesson with its contents and quiz
POST/modules/:moduleId/lessonscourse.edit_lessonsCreate a lesson — title, description, status, isFreePreview, contents, quiz
PUT/lessons/:lessonIdcourse.edit_lessonsUpdate a lesson — the same fields
POST/modules/:moduleId/lessons/reordercourse.edit_lessonsReorder — orderedIds, every lesson id in the new order
DELETE/lessons/:lessonIdcourse.delete_contentDelete a lesson

Students

MethodPathPermissionWhat it does
GET/studentsstudentList students — searchText, limit, after
GET/students/:idstudentOne student with enrollments and progress
POST/studentsstudent.createCreate 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

MethodPathPermissionWhat it does
GET/settingssettingThe published settings groups: general, appearance, email, studentAccess, translation
PUT/settingsany of setting.edit_general, setting.edit_appearance, setting.edit_notificationsUpdate settings, as flat dot-paths

Translations

MethodPathPermissionWhat it does
GET/translationssettingOne locale with code, every locale without it
PUT/translationssetting.edit_translationPatch locales — defaultLocale, publish, values

Notification emails

Part of Settings, so both take setting.edit_notifications.

MethodPathPermissionWhat it does
POST/notifications/test-emailsetting.edit_notificationsSend a test email — templateKey
PUT/notification-templates/:idsetting.edit_notificationsUpdate 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/:lessonId and GET /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/modules means "no modules", not "no course". Likewise GET /translations?code=xx-YY answers 200 for a locale that does not exist, with found: false in 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 are general.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.
  • courseIds must be an array, even for a single course. "courseIds": "c1" is a 400 INVALID_INPUT.
  • A required field left out is a 400 INVALID_INPUT, and a blank string counts as left out. POST /students needs email; POST /notifications/test-email needs templateKey. A templateKey that is well-formed but does not exist is a 404 NOT_FOUND, with no mail sent.
  • thumbnailUrl must be an https URL. There is no file upload over this API.
  • POST /courses never accepts certificate, whatever the key holds — it always comes back ignored. Set it afterwards with PUT /courses/:id, which is where course.manage_certificate applies.
  • POST /notifications/test-email takes 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 /translations patches 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=10abc does 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 sendWhat you get
?after= (empty)Page one — not an error
?after= with the last row's idThe next page
?after= with a malformed id (a/b, .., over 1500 bytes)400 INVALID_INPUT
?after= with a well-formed id that no longer exists400 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 — always false on 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 limit and you get every row — not 20, not 100. The cap of 100 only applies to a limit you actually sent (?limit=500 becomes 100).
  • Neither ever carries meta.pagination. There is no hasNext to read, only meta.count.
  • /lessons takes no offset — only limit. It can be truncated, not paged, and nothing tells you how much was left behind. Ask for everything, or accept the cut.
  • /modules takes offset (capped at 1000) and that is the only way forward. Since there is no hasNext, page until a call returns fewer rows than the limit you 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.

CodeStatusWhat happenedWhat to do
AUTH_FAILED401Missing or malformed header, unknown key, invalid OAuth tokenFix the credential; don't retry as-is
AUTH_FAILED500Authentication itself failed internallyRetry with backoff
KEY_EXPIRED401The key expired or was deactivatedCreate a new key in Settings > Developers
SCOPE_DENIED403The key lacks the permission this endpoint needsTick that permission on the key
FEATURE_LOCKED403A paid feature (certificate, quiz, translation caps) is not unlockedTell the merchant — the feature has to be unlocked in the app; don't retry
SHOP_NOT_FOUND404The key's shop no longer existsStop
NOT_FOUND404Unknown path, or the record does not existCheck the id or the path
METHOD_NOT_ALLOWED405The path exists, the method does notUse the documented method
NOT_IMPLEMENTED501The method is not one this API implementsUse the documented method
RATE_LIMITED429Quota exhaustedWait the Retry-After seconds
INVALID_INPUT400Validation failed — a bad value, a malformed cursor, a wrong typeFix the arguments
CURSOR_NOT_FOUND400after is a well-formed id that no longer existsRestart the list from page one
SEARCH_NOT_PAGEABLE400searchText was sent together with afterSend searchText alone, or drop it and page with after
SEARCH_UNAVAILABLE503The search backend did not answerRetry shortly, or list without searchText
COURSE_HAS_STUDENTS400Delete refused; details.studentCount says how manyUnenroll first, or keep the course
INTERNAL_ERROR500Something unplanned; error is always Internal server errorRetry 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.
Products
Avada SEO SuiteAvada AEO OptimizerAvada AI Blog BuilderAvada Product CopyAvada Images & Page Speed UpAvada Shipping LabelsAvada Backups & Restore
Resources
DocumentationSEO Suite DocsBlog DocsSpeed DocsShipping Labels DocsBackups & Restore Docs
Company
Avada GroupPrivacy Policy
© 2026 Avada Group. All rights reserved.