REST API reference

A REST API for reading Manabu2 learning content and recording learner progress. It serves both cases: pulling courses and lessons into your own service, and recording progress from a learning app.

Base URL: https://api.manabu2.com/api/v1 | Interactive reference (Scalar)OpenAPI JSON

Versioning

The public API lives under /api/v1. If a breaking change becomes necessary it will ship as /api/v2, with v1 kept alive through a migration period. Adding a field is not considered breaking, so write clients that ignore fields they do not recognise.

The older unversioned URLs (for example /api/courses) still work for now and are forwarded internally to /api/v1, but the response carries a Deprecation: true header and they will eventually be removed. Point new integrations at /api/v1 directly.

Schema (OpenAPI / Swagger)

Every endpoint is published as a machine-readable OpenAPI 3 document. Use it to generate a typed client, import into Postman, or diff the schema between releases.

Each operation carries an x-required-scope extension, so the scope it needs can be read straight from the document. The value is generated from the attribute that actually enforces it, so it cannot drift from the implementation.

Generating a client, using openapi-generator:

npx @openapitools/openapi-generator-cli generate   -i https://api.manabu2.com/openapi/v1.json   -g typescript-fetch   -o ./src/manabu2

1. Authentication

Every endpoint requires authentication. There are two credential types, for two different jobs.

Learner token (JWT)

For acting as a specific learner, such as from a learning app. Can read and write that learner's own progress.

Authorization: Bearer <jwt>
Organization API key

For server-to-server integrations. Issued per organization and limited to the scopes it was granted. It is not tied to any individual.

X-Api-Key: mnb_live_…
Authorization: Bearer mnb_live_…
Getting a learner token
POST https://api.manabu2.com/api/v1/auth/login
Content-Type: application/json

{
  "email": "learner@example.com",
  "password": "…",
  "clientId": "manabu2-learner-app"
}
{
  "accessToken": "eyJhbGciOi…",
  "expiresAt": "2026-07-30T15:00:00Z",
  "refreshToken": "3pQ7…",
  "scopes": ["read:catalog", "read:content", "read:progress", "write:progress"],
  "user": { "id": "…", "email": "learner@example.com", "displayName": "…" }
}

Login returns two tokens. The accessToken goes on every request and lasts 60 minutes. The refreshToken lasts 30 days and buys you a new pair once the access token expires. Access tokens are deliberately short-lived because they cannot be revoked — the API only verifies their signature — so the refresh token is what actually carries, and can end, the session.

clientId — identifying your application

clientId says which app is signing in. Each registered app has a ceiling on the scopes it may request, and an issued token never exceeds it.

A token's scopes are the intersection of what the user's roles allow and what the app is registered to request. An organization admin signing into a learner app does not get write:members on that token — an app should not carry authority it has no use for.
  • Omitting clientId yields only the four learner scopes (read:catalog, read:content, read:progress, write:progress). Leaving it out is never a way to obtain administrative scopes.
  • An unregistered or deactivated clientId receives no scopes at all, so switching an app off actually stops it. Login returns 400 for one.
  • Refreshing re-applies the same app's ceiling, so rotating a token can never widen what the app may do.
  • Today only our own applications can be issued a clientId, registered through configuration. Registration for third-party apps will arrive with the OAuth authorization flow.
Signing in without handling passwords (OAuth)

The authorization-code flow with PKCE. Your app sends the user to manabu2.com to sign in, and gets back a short-lived code it exchanges for tokens. The password is typed on our page and never passes through your app.

Use this for anything running on a device you do not control — a mobile or desktop app, or a single-page app. It is also what gives single sign-on: a user already signed in at manabu2.com is returned to your app immediately, without another prompt.

1. Generate a PKCE pair. The verifier stays in your app; only its SHA-256 hash goes over the wire, so intercepting the redirect is not enough to redeem the code.

code_verifier  = base64url(random 32–64 bytes)      // keep this
code_challenge = base64url(sha256(code_verifier))   // send this

2. Open this URL in the system browser (not an embedded web view — an embedded one can read the password, which defeats the point).

GET https://manabu2.com/connect/authorize
  ?client_id=manabu2-mobile
  &redirect_uri=manabu2%3A%2F%2Fauth%2Fcallback
  &response_type=code
  &scope=read%3Acatalog%20read%3Acontent%20read%3Aprogress%20write%3Aprogress
  &state=<random, checked on the way back>
  &code_challenge=<from step 1>
  &code_challenge_method=S256

3. The user signs in and is redirected back to you. Check that state matches what you sent before doing anything else.

manabu2://auth/callback?code=f5kIhYnGah1I…&state=<the value you sent>

4. Exchange the code for tokens. A JSON body works too, if that is easier for your HTTP client.

POST https://api.manabu2.com/api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=f5kIhYnGah1I…
&client_id=manabu2-mobile
&redirect_uri=manabu2://auth/callback
&code_verifier=<from step 1>

The response is identical to the one from /auth/login: accessToken, expiresAt, refreshToken, scopes and user. Renew it the same way, with POST /api/v1/auth/refresh.

Rules worth knowing before you build
  • redirect_uri must match a registered value exactly — scheme, host, port, path and trailing slash. There is no prefix or wildcard matching, because a loose match is how authorization codes get stolen.
  • PKCE is required for every client, and code_challenge_method must be S256. The plain method is rejected.
  • A code works exactly once. Presenting it twice is treated as theft: the session that code created is revoked, and the user must sign in again. If you see this, look for a retry in your own code.
  • A code expires after 60 seconds. Exchange it as soon as you receive it.
  • Ask for the narrowest scope you need. You receive the intersection of what you requested, what your client is registered for, and what the user's roles allow — asking for more is not an error, the excess is simply dropped.
  • Token-endpoint errors use the OAuth error body ({ error, error_description }) rather than the problem+json used elsewhere in this API, because that is what OAuth clients expect.
Sign-in-free accounts for consumer apps (device registration)

For apps where a sign-up screen is the wrong first experience. The app registers the installation, a learner account is created for it, and the person starts using the app immediately — no email, no password, nothing typed.

Enabled per client. Registering an app does not grant this — being able to create user accounts in an organization is a separate decision, so ask us to turn it on for your client id.

1. On first launch, register the installation. Do this once, ever.

POST https://api.manabu2.com/api/v1/devices/register
Content-Type: application/json

{
  "clientId": "wakaroute",
  "deviceId": "<stable per-install identifier, 16+ chars>",
  "platform": "ios"
}
{
  "deviceSecret": "mnbd_…",
  "isNewAccount": true,
  "auth": {
    "accessToken": "eyJhbGciOi…",
    "refreshToken": "3pQ7…",
    "scopes": ["read:catalog", "read:content", "read:progress", "write:progress"],
    "user": { "id": "…", "email": "", "displayName": "" }
  }
}
Store deviceSecret in the Keychain or Keystore before doing anything else. It is shown only in this response, and it is the only thing that proves this installation owns the account. Losing it means the person loses their history.

2. If the refresh token is lost but the secret survives, exchange the secret for a fresh pair. While you still hold a refresh token, use POST /api/v1/auth/refresh instead.

POST https://api.manabu2.com/api/v1/devices/token

{ "clientId": "wakaroute", "deviceId": "…", "deviceSecret": "mnbd_…" }

3. Optional, and worth prompting for once the person has progress worth keeping: attach an email so the account survives a new device. The user id does not change, so everything already recorded carries over.

POST https://api.manabu2.com/api/v1/me/link
Authorization: Bearer <accessToken>

{ "email": "student@example.com", "password": "…", "displayName": "…" }
  • The device id is a label, not a credential. It says which installation is calling; the secret is what authenticates. Presenting a device id alone gets you 401 — which is deliberate, because device identifiers are readable off the device and reset on reinstall.
  • Registering again with the same device id returns the same account with a fresh secret, not a second empty one. Safe to call if you are unsure whether this install has registered before.
  • Device-registered learners do not consume the organization's paid seats.
  • Registration is limited to 5 per minute per IP. A real device registers once in its lifetime, so this only affects test harnesses and abuse.
  • Linking works once. An account that already has an email returns 409 already_linked; changing an address is an account-settings operation with its own confirmation.
Renewing an access token

Send the refresh token and you get a new access token and a new refresh token. Always replace your stored copy with the one you just received.

POST https://api.manabu2.com/api/v1/auth/refresh
Content-Type: application/json

{ "refreshToken": "3pQ7…" }
Refresh tokens are single-use. Every refresh returns a new one — store it and send that next time. Re-sending a spent token is refused with 401 refresh_token_reused, but the session itself stays valid: retry with the most recent refresh token.
Signing out

Ends that session. It needs no authentication so it still works after the access token has expired, and always returns 204 so it cannot be used to probe whether a token exists. Note that access tokens already issued are not revoked — they stay valid until they expire, at most 60 minutes.

POST https://api.manabu2.com/api/v1/auth/logout
Content-Type: application/json

{ "refreshToken": "3pQ7…" }
Issuing an API key

Keys are issued and revoked using an organization admin's or developer's token. An API key cannot issue another key - otherwise a leaked key could mint itself successors, and revoking the original would no longer contain the incident.

You can also issue and revoke keys without curl, under Developer -> API keys. Both organization admins and developers can issue a key, but only an admin can grant the organization-wide scopes.
POST https://api.manabu2.com/api/v1/organizations/{organizationId}/api-keys
Authorization: Bearer <admin-jwt>
Content-Type: application/json

{ "name": "Zapier integration", "scopes": ["read:catalog", "read:content"] }
{
  "key": "mnb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "warning": "Store this key now. It is hashed on the server and cannot be shown again.",
  "apiKey": { "id": "…", "maskedKey": "mnb_live_…a1b2", "scopes": ["read:catalog","read:content"] }
}
The key itself is returned only once, at creation. Only a hash is stored server-side, so it cannot be shown again. If you lose it, revoke it and issue a new one.

2. Scopes

A scope is the upper bound on what a credential may attempt. Holding a scope still only exposes your own organization's content, plus anything public.

Scope Grants Learner token
read:catalogList and fetch courses, paths and certificates
read:contentFetch lesson bodies, materials and quiz questions
read:progressRead your own progress, assignments and certificates
write:progressRecord lesson views and completions, submit quizzes
read:membersRead organization members
write:membersCreate, update or deactivate organization members
write:contentCreate and edit courses, lessons, quizzes and tests. Granted by the curriculum-manager role.
read:reportsRead aggregate reporting and insights
webhooks:manageCreate and manage webhook subscriptions

webhooks:manage is defined but has no endpoints yet, so granting it has no effect today. read:members, write:members and read:reports are used by the organization management endpoints below.

A scope has two ceilings: the user's own roles, and what the client is permitted. A token carries the intersection, so missing either one means the scope is not granted. A client that registers itself — as MCP clients do — is capped at the learner scopes; anything beyond that is approved per organization by an admin under Developer → Apps → Connected clients.

3. What you can see

Visibility follows the rules below. This is the multi-tenant boundary itself, so it is worth reading closely.

Content Visible when
Courseit is published, AND it is either public or owned by your organization
Learning pathit is public, or owned by your organization
Lesson / materialit is published, AND you can see the course it belongs to
Certificateit is owned by your organization (certificates have no public catalog)
  • Drafts never appear. An unpublished course or lesson is invisible through this API even to members of the organization that owns it - authoring happens in the web app.
  • 404 means both 'does not exist' and 'not yours'. The two are deliberately not distinguished, because confirming that something exists is itself a disclosure.
  • Content is authored per language with no fallback between languages. A ja-JP course is not returned for en-US.

4. Endpoints

Catalog and content (read)
Method URL Required scope Summary
GET/api/v1/coursesread:catalogList courses, with paging, language filter and free-text search
GET/api/v1/courses/{id}read:catalogOne course, with its sections and published lessons
GET/api/v1/lessons/{id}read:contentOne lesson: body, video, slides, materials and quiz
GET/api/v1/materials/{id}read:contentFetch one material
GET/api/v1/pathsread:catalogList learning paths
GET/api/v1/paths/{id}read:catalogOne path, with its courses in learning order
GET/api/v1/certificatesread:catalogList your organization's certificates
Learner progress (read and write)

These describe a specific person, so they require a learner token. Calling them with an API key returns 403.

Method URL Required scope Summary
GET/api/v1/meread:progressThe signed-in learner and their organizations
GET/api/v1/me/progressread:progressProgress across started courses
GET/api/v1/me/progress/{courseId}read:progressProgress for one course, broken down by lesson
GET/api/v1/me/quiz-attemptsread:progressThe caller's own past quiz results — score, pass/fail and when — newest first. Filter with from / to as inclusive Asia/Tokyo calendar dates.
GET/api/v1/me/test-attemptsread:progressThe caller's own past test sittings, newest first. For timed tests it also says whether the sitting finished inside the limit (null when that cannot be answered).
GET/api/v1/me/pathsread:progressLearning paths assigned to the learner
GET/api/v1/me/certificatesread:progressCertificates the learner has been awarded
GET/api/v1/me/assignmentsread:progressCourses assigned to the learner, directly or via a path
GET/api/v1/me/goalsread:progressThe learner's goals in preference order, with the binding deadline
PUT/api/v1/me/goalswrite:progressReplace one kind of goal outright; array order is preference order
GET/api/v1/me/target-schoolsread:progressThe learner's 志望校 (alias for /me/goals?type=high_school)
PUT/api/v1/me/target-schoolswrite:progressReplace the learner's 志望校 list
POST/api/v1/me/study-sessions/startwrite:progressStart the study timer; the server stamps both ends
POST/api/v1/me/study-sessions/{sessionId}/stopwrite:progressStop the timer. Past the 2h cap it returns abandoned and counts zero
POST/api/v1/me/study-sessionswrite:progressRecord a finished session offline; clientSessionId prevents double-counting
GET/api/v1/me/study-sessionsread:progressThe activity log, most recent first
GET/api/v1/me/study-summaryread:progressDense per-day or per-month totals for the calendar, zero days included
GET/api/v1/me/study-streakread:progressConsecutive study days, computed server-side so it survives a device change
DELETE/api/v1/mewrite:progressClose the learner's own account (App Store 5.1.1(v)); device rows go too, so the next launch is a new account
POST/api/v1/lessons/{id}/viewwrite:progressRecord that the learner opened the lesson (idempotent)
POST/api/v1/lessons/{id}/completewrite:progressMark the lesson complete and return the updated course progress
POST/api/v1/lessons/{id}/quiz/submitwrite:progressSubmit quiz answers. Graded server-side; passing also completes the lesson
Authentication and sessions

The learner token lifecycle. None of this applies to API keys — a key has no session or expiry, and is revoked by an organization admin instead.

Method URL Credential Summary
POST/api/v1/auth/loginAuthenticate with email and password; returns an access and refresh token
POST/api/v1/auth/refreshrefreshTokenExchange a refresh token for a new pair (single-use, with reuse detection)
POST/api/v1/auth/logoutrefreshTokenEnd that session (idempotent, always 204)
POST/api/v1/auth/logout-allaccessTokenEnd every session, on all devices
GET/api/v1/auth/sessionsaccessTokenList active sessions with their creation time and device details
Organization management (members, departments, assignment, reporting)

For HR system integration. Requires an organization admin or HR learner token, or an organization API key granted the relevant scopes. Either way, the caller's authority over the organization in the route is checked separately.

Method URL Required scope Summary
GET/api/v1/organizations/{orgId}/membersread:membersList members, including pending invitations
GET/api/v1/organizations/{orgId}/members/{userId}read:membersGet one member
POST/api/v1/organizations/{orgId}/memberswrite:membersInvite by email; consumes one seat
PUT/api/v1/organizations/{orgId}/members/{userId}/roleswrite:membersReplace roles wholesale — omitted roles are cleared
DELETE/api/v1/organizations/{orgId}/members/{userId}write:membersRemove a member and free their seat
GET/api/v1/organizations/{orgId}/seatsread:membersSeat usage (used / total / available)
GET/api/v1/organizations/{orgId}/departmentsread:membersList departments
POST/api/v1/organizations/{orgId}/departmentswrite:membersCreate a department
DELETE/api/v1/organizations/{orgId}/departments/{deptId}write:membersDelete a department; members stay in the organization
PUT / DELETE…/departments/{deptId}/members/{userId}write:membersAdd or remove a member from a department
PUT / DELETE…/members/{userId}/paths/{pathId}write:membersAssign or unassign a learning path for one member
PUT / DELETE…/departments/{deptId}/paths/{pathId}write:membersAssign or unassign a path for a whole department
GET/api/v1/organizations/{orgId}/progressread:reportsOrganization progress: per-member rows plus a rollup
Behaviours worth knowing before you integrate: re-inviting an existing member is a no-op rather than an error, so a sync is safe to run repeatedly. A full plan returns 402 seat_limit_reached, and pending invitations consume seats too — check /seats before a bulk run. Demoting or removing the last organization admin returns 409 last_admin. Role updates replace the whole set, so send every role you want kept.
API key management

Requires an organization admin's learner token.

Method URL Summary
GET/api/v1/organizations/{orgId}/api-keysList the organization's keys, including revoked ones (never key material)
POST/api/v1/organizations/{orgId}/api-keysIssue a key - the only response that contains the secret
DELETE/api/v1/organizations/{orgId}/api-keys/{keyId}Revoke a key immediately
Content authoring

All require write:content plus the curriculum-manager (or admin) role in the target organization. Holding the scope is not sufficient on its own.

Method URL Required scope Summary
POST/api/v1/organizations/{organizationId}/courseswrite:contentCreate a course
PUT/api/v1/organizations/{organizationId}/courses/{courseId}write:contentUpdate a course
POST/api/v1/organizations/{organizationId}/courses/{courseId}/publishwrite:contentPublish or unpublish a course
DELETE/api/v1/organizations/{organizationId}/courses/{courseId}write:contentDelete a course. Needs cascade=true when it has content
GET/api/v1/organizations/{organizationId}/courses/{courseId}/sectionsread:catalogList sections, drafts included
POST/api/v1/organizations/{organizationId}/courses/{courseId}/sectionswrite:contentAdd a section
PUT/api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}write:contentUpdate a section
DELETE/api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}write:contentDelete a section. Needs cascade=true when it has lessons
GET/api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessonsread:contentList lessons, without their bodies
POST/api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessonswrite:contentCreate a lesson
PUT/api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons/{lessonId}write:contentReplace a lesson's content
POST/api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons/{lessonId}/publishwrite:contentPublish or unpublish a lesson
DELETE/api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons/{lessonId}write:contentDelete a lesson
GET/api/v1/organizations/{organizationId}/materialsread:contentList materials
POST/api/v1/organizations/{organizationId}/materialswrite:contentCreate a material
PUT/api/v1/organizations/{organizationId}/lessons/{lessonId}/materialswrite:contentAttach a material to a lesson
Tests

The sit-the-test read never returns the answer key. Only the org-scoped authoring read includes it.

Method URL Required scope Summary
GET/api/v1/tests/{testId}read:contentFetch a test to sit — no answer key
POST/api/v1/tests/{testId}/submitwrite:progressSubmit answers and get the graded result
GET/api/v1/me/testsread:progressTests available to me, with my latest result
GET/api/v1/organizations/{organizationId}/testsread:contentList the organization's tests
GET/api/v1/organizations/{organizationId}/tests/{testId}write:contentFetch a test with its answer key, for editing
POST/api/v1/organizations/{organizationId}/testswrite:contentCreate a test
POST/api/v1/organizations/{organizationId}/tests/{testId}/questionswrite:contentAdd a question with its options
Reporting and subscription

Aggregate only. Per-person progress stays on the existing endpoint behind read:members. Subscription data is read-only — there is no purchase or plan-change endpoint.

Method URL Required scope Summary
GET/api/v1/organizations/{organizationId}/overviewread:reportsThe organization at a glance
GET/api/v1/organizations/{organizationId}/reports/completionread:reportsCompletion by course
GET/api/v1/organizations/{organizationId}/reports/departmentsread:reportsCompletion by department
GET/api/v1/organizations/{organizationId}/reports/trendread:reportsMonthly completion trend
GET/api/v1/organizations/{organizationId}/subscriptionread:reportsPlan and seat position — read-only
Search
Method URL Required scope Summary
GET/api/v1/search?q=read:catalogFull-text search across courses and paths
AI drafting

Returns a draft and saves nothing — review it, then pass it to the authoring endpoints. Billed per call and rate limited separately from reads.

Method URL Required scope Summary
POST/api/v1/organizations/{organizationId}/ai/course-outlinewrite:contentDraft a course outline
POST/api/v1/organizations/{organizationId}/ai/lesson-bodywrite:contentDraft a lesson body
POST/api/v1/organizations/{organizationId}/ai/quiz-questionswrite:contentDraft quiz questions, with the answer key

5. Example request

Listing courses with an API key.

curl -H "X-Api-Key: mnb_live_…" \
     "https://api.manabu2.com/api/v1/courses?culture=en-US&pageSize=2"
{
  "items": [
    {
      "id": "0c1f…",
      "title": "Introduction to AI literacy",
      "culture": "en-US",
      "level": "Beginner",
      "estimatedMinutes": 90,
      "isPublic": true,
      "sectionCount": 4,
      "lessonCount": 12
    }
  ],
  "page": 1,
  "pageSize": 2,
  "totalItems": 137,
  "totalPages": 69
}

6. Paging and filtering

List endpoints share one response shape: items, page, pageSize, totalItems, totalPages.

  • page — Page number (1-based, default 1)
  • pageSize — Items per page (default 20, max 100 - larger values are clamped rather than rejected)
  • culture — Content language (ja-JP / en-US)
  • organizationId — Narrow to one organization you can already see
  • q — Free-text match on course title and description

7. Errors

Errors are JSON and carry error, message and status. When a scope is missing, the required scope is named too.

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://manabu2.com/ja-JP/docs/api#errors",
  "title": "Forbidden",
  "status": 403,
  "detail": "This endpoint requires the 'write:progress' scope.",
  "code": "insufficient_scope",
  "requiredScope": "write:progress"
}
Branch on code, never on detail — detail is prose for humans and may be reworded without notice. Error responses use Content-Type application/problem+json (RFC 7807).
Error codes
code Status Meaning
invalid_request400Malformed request, such as a missing required field
invalid_credentials401Wrong email or password
invalid_grant401The refresh token is invalid, revoked or expired
refresh_token_reused401An already-used refresh token was presented; the whole session has been revoked
unauthorized401No credential, or one that is not valid
insufficient_scope403The credential lacks a scope; requiredScope names the one needed
user_credential_required403An API key called something that requires a learner token
admin_required403Only an organization admin may do this
not_found404Does not exist, or is not yours
idempotency_key_reused409This Idempotency-Key was already used for a different request — use a new one
idempotency_key_in_progress409An identical request with this key is still being processed — retry shortly
rate_limit_exceeded429Rate limit exceeded
HTTP statuses
Status Meaning
400Malformed request (missing required field, empty scope list)
401No credential, or one that is invalid, revoked or expired
403Authenticated but not permitted (missing scope, or an action that needs a learner token)
404Does not exist, or is not yours
409Conflict — the request clashes with something already recorded, most often an Idempotency-Key reused for a different request
429Rate limit exceeded (wait the number of seconds in the Retry-After header, then retry)
500Internal server error

8. Rate limits

Limits apply per caller: the API key, then the user, and only otherwise the IP address. An authenticated call is never caught by the anonymous allowance just because it shares an IP with other traffic.

Caller Limit
API key or learner token (authenticated)300 / min
Unauthenticated (per IP address)60 / min
AI generation (/api/ai/*)10 / min

Exceeding a limit returns 429. The response carries Retry-After and X-RateLimit-* headers, so you never have to guess how long to wait.

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785418481

{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Retry in 60 seconds.",
  "retryAfterSeconds": 60,
  "status": 429
}

X-RateLimit-* headers are sent on 429 responses only; successful responses do not carry a remaining count. Limits use a fixed one-minute window, and excess requests are rejected immediately rather than queued - failing fast is easier to handle than being held open.

9. Webhooks

Instead of polling, have events POSTed to a URL you control. Endpoints are registered per organization and require the webhooks:manage scope.

Registering an endpoint
POST https://api.manabu2.com/api/v1/organizations/{organizationId}/webhooks
X-Api-Key: mnb_live_…
Content-Type: application/json

{
  "url": "https://example.com/hooks/manabu2",
  "eventTypes": ["lesson.completed", "certificate.issued"],
  "description": "HR system sync"
}
{
  "secret": "whsec_…",
  "warning": "Store this signing secret now. It is not shown again.",
  "webhook": { "id": "…", "url": "https://example.com/hooks/manabu2", "isActive": true }
}
The signing secret is returned only when you register, and cannot be shown again. If you lose it, delete the endpoint and register a new one. URLs must be https, since payloads carry learner data.
Event types
Event Sent when
lesson.completedA learner completes a lesson for the first time (re-completing does not fire again)
certificate.issuedA certificate is awarded, whether automatically or by hand
pingA test delivery. Only ever produced by the /test endpoint

course.started, member.added and subscription.activated are defined as types but nothing raises them yet, so they will not arrive. Omitting eventTypes subscribes to everything, including event types added later.

What we send
POST https://example.com/hooks/manabu2
X-Manabu-Event: lesson.completed
X-Manabu-Delivery: 9f2c…
X-Manabu-Timestamp: 1785424800
X-Manabu-Signature: sha256=4a7f…

{
  "id": "96ea88a005cb461798e4659e3d9cb9a8",
  "type": "lesson.completed",
  "createdAt": "2026-07-31T09:20:00Z",
  "organizationId": "…",
  "data": { "userId": "…", "lessonId": "…", "courseId": "…" }
}
Verifying the signature

The signature is HMAC-SHA256 over the timestamp, a period, and the body - the same construction Stripe and GitHub use. Anyone who learns your URL can POST to it, so verify before you act on a payload.

// Node.js
const crypto = require("crypto");

function verify(req, secret) {
  const timestamp = req.headers["x-manabu-timestamp"];
  const signature = req.headers["x-manabu-signature"];
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${req.rawBody}`)   // the raw body, not the parsed object
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Sign the raw body, before any JSON parsing. Re-serializing a parsed object changes whitespace and key order, and the signature will not match. Compare in constant time (timingSafeEqual or equivalent). The timestamp is part of the signed material, so you can also reject deliveries that are implausibly old.
Retries

Anything other than a 2xx, or no response at all, is retried.

  • Up to 6 attempts, backing off 30s, 2m, 10m, 30m, then hourly - giving up after roughly two hours.
  • Only 2xx counts as delivered. If your handler is slow, return 200 first and do the work asynchronously; we time out after 10 seconds.
  • Expect the same event more than once. The payload id is stable across retries, so use it to skip work you have already done.
  • Check outcomes with /deliveries, and re-send a failed one with /replay.
Management endpoints
Method URL Summary
GET/api/v1/webhook-eventsList the event types you can subscribe to
GET…/organizations/{orgId}/webhooksList registered endpoints (never includes secrets)
POST…/organizations/{orgId}/webhooksRegister an endpoint and receive its signing secret
DELETE…/webhooks/{id}Delete an endpoint and its delivery history
POST…/webhooks/{id}/testSend a ping to check wiring - goes only to that endpoint
GET…/webhooks/{id}/deliveriesRecent deliveries with attempts, HTTP status and next retry
POST…/webhooks/deliveries/{id}/replayRe-queue a failed delivery

All of these need the webhooks:manage scope. Registering, deleting, testing and replaying additionally require organization admin or HR.

MCP (connecting an AI client)

The same data, exposed to AI clients as tools. OAuth only — API keys are refused here.

Endpoint https://api.manabu2.com/mcp
1. Authentication Authorization: Bearer <jwt>

Both Streamable HTTP (Mcp-Session-Id header) and the older HTTP+SSE transport are supported; the client picks.

Scopes decide which tools appear; anything the token cannot use is absent from tools/list rather than advertised and refused.

Per-organization authorization

A scope only says the holder may write somewhere. A curriculum manager of one organization carries write:content everywhere, so every write re-checks the caller's role in the organization it names, and is audited.

A self-registered client starts with the learner scopes only. To let it author or assign, an organization admin approves it explicitly under Developer → Apps → Connected clients.
Error codes

Tool errors carry a stable string in the JSON-RPC error as data.code.

data.code
invalid_requestA missing or malformed argument.
not_foundIt does not exist, or you cannot see it — the two are deliberately not distinguished.
forbiddenYour role in that organization is not sufficient.
insufficient_scopeThe credential lacks the required scope.
unknown_toolNo such tool.
user_credential_requiredAn API key cannot be used here; connect by signing in.

MCP (connecting an AI client) →

10. Idempotency

A request that times out tells you nothing about whether the server acted on it. Send an Idempotency-Key header on a write and you can retry safely: the first call does the work, and every repeat of the same request returns the response the first one produced, without running it again. This matters most for quiz submissions, where each call would otherwise record another graded attempt.

POST https://api.manabu2.com/api/v1/lessons/{lessonId}/quiz/submit
Authorization: Bearer <jwt>
Idempotency-Key: 9f1c2d4e-7a3b-4c11-9e55-2b8d0f6a1c37
Content-Type: application/json

{ "answers": [ { "questionId": "q1", "choiceId": "c3" } ] }

A replayed response is byte-for-byte the original, with the same status code, plus one extra header:

HTTP/1.1 200 OK
Idempotency-Replayed: true
What you send What you get
Same key, same requestThe original response, with Idempotency-Replayed: true
Same key, different request body or path409 idempotency_key_reused
Same key while the first call is still running409 idempotency_key_in_progress
The first call failed with a 5xxNothing is stored, so your retry runs for real — a transient failure is never frozen into a permanent one
  • Optional. Requests without the header behave exactly as before. The header is honoured on POST, PUT and PATCH; GET and DELETE are already repeatable, so it is ignored there.
  • A key is remembered for 24 hours. After that it can be reused for a new request.
  • Keys are scoped to your credential, so your key can never collide with another customer's. Use a fresh value — a UUID is ideal — for each distinct operation.
  • Not supported on /auth endpoints: we do not store issued tokens. Refresh already protects against replay through token families.

11. Not available yet

Stated plainly, so you do not go looking.

  • No tiered free/paid plan quotas, and no persisted usage counting or billing hooks. The rate limits above apply, but there is no monthly usage allowance.
  • There is no screen for managing webhooks yet - they are registered through the API only. The delivery worker also assumes a single instance, so scaling the API out could deliver an event more than once.
  • No organization or member management endpoints (the scopes are reserved only).
  • URLs are unversioned. The versioning approach is undecided, and changing it later would be a breaking change.
  • No language SDKs or Postman collection. Generate clients from the OpenAPI JSON.
If you would like to talk through an integration, get in touch.

Next: Webhook docs →