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.
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.
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.
| OpenAPI document | https://api.manabu2.com/openapi/v1.json |
| Try it in the browser | https://api.manabu2.com/scalar/v1 |
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.
- 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.
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.
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": "" }
}
}
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…" }
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.
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"] }
}
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:catalog | List and fetch courses, paths and certificates | ✔ |
read:content | Fetch lesson bodies, materials and quiz questions | ✔ |
read:progress | Read your own progress, assignments and certificates | ✔ |
write:progress | Record lesson views and completions, submit quizzes | ✔ |
read:members | Read organization members | — |
write:members | Create, update or deactivate organization members | — |
write:content | Create and edit courses, lessons, quizzes and tests. Granted by the curriculum-manager role. | — |
read:reports | Read aggregate reporting and insights | — |
webhooks:manage | Create 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 |
|---|---|
| Course | it is published, AND it is either public or owned by your organization |
| Learning path | it is public, or owned by your organization |
| Lesson / material | it is published, AND you can see the course it belongs to |
| Certificate | it 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/courses | read:catalog | List courses, with paging, language filter and free-text search |
| GET | /api/v1/courses/{id} | read:catalog | One course, with its sections and published lessons |
| GET | /api/v1/lessons/{id} | read:content | One lesson: body, video, slides, materials and quiz |
| GET | /api/v1/materials/{id} | read:content | Fetch one material |
| GET | /api/v1/paths | read:catalog | List learning paths |
| GET | /api/v1/paths/{id} | read:catalog | One path, with its courses in learning order |
| GET | /api/v1/certificates | read:catalog | List 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/me | read:progress | The signed-in learner and their organizations |
| GET | /api/v1/me/progress | read:progress | Progress across started courses |
| GET | /api/v1/me/progress/{courseId} | read:progress | Progress for one course, broken down by lesson |
| GET | /api/v1/me/quiz-attempts | read:progress | The 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-attempts | read:progress | The 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/paths | read:progress | Learning paths assigned to the learner |
| GET | /api/v1/me/certificates | read:progress | Certificates the learner has been awarded |
| GET | /api/v1/me/assignments | read:progress | Courses assigned to the learner, directly or via a path |
| GET | /api/v1/me/goals | read:progress | The learner's goals in preference order, with the binding deadline |
| PUT | /api/v1/me/goals | write:progress | Replace one kind of goal outright; array order is preference order |
| GET | /api/v1/me/target-schools | read:progress | The learner's 志望校 (alias for /me/goals?type=high_school) |
| PUT | /api/v1/me/target-schools | write:progress | Replace the learner's 志望校 list |
| POST | /api/v1/me/study-sessions/start | write:progress | Start the study timer; the server stamps both ends |
| POST | /api/v1/me/study-sessions/{sessionId}/stop | write:progress | Stop the timer. Past the 2h cap it returns abandoned and counts zero |
| POST | /api/v1/me/study-sessions | write:progress | Record a finished session offline; clientSessionId prevents double-counting |
| GET | /api/v1/me/study-sessions | read:progress | The activity log, most recent first |
| GET | /api/v1/me/study-summary | read:progress | Dense per-day or per-month totals for the calendar, zero days included |
| GET | /api/v1/me/study-streak | read:progress | Consecutive study days, computed server-side so it survives a device change |
| DELETE | /api/v1/me | write:progress | Close 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}/view | write:progress | Record that the learner opened the lesson (idempotent) |
| POST | /api/v1/lessons/{id}/complete | write:progress | Mark the lesson complete and return the updated course progress |
| POST | /api/v1/lessons/{id}/quiz/submit | write:progress | Submit 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/login | — | Authenticate with email and password; returns an access and refresh token |
| POST | /api/v1/auth/refresh | refreshToken | Exchange a refresh token for a new pair (single-use, with reuse detection) |
| POST | /api/v1/auth/logout | refreshToken | End that session (idempotent, always 204) |
| POST | /api/v1/auth/logout-all | accessToken | End every session, on all devices |
| GET | /api/v1/auth/sessions | accessToken | List 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}/members | read:members | List members, including pending invitations |
| GET | /api/v1/organizations/{orgId}/members/{userId} | read:members | Get one member |
| POST | /api/v1/organizations/{orgId}/members | write:members | Invite by email; consumes one seat |
| PUT | /api/v1/organizations/{orgId}/members/{userId}/roles | write:members | Replace roles wholesale — omitted roles are cleared |
| DELETE | /api/v1/organizations/{orgId}/members/{userId} | write:members | Remove a member and free their seat |
| GET | /api/v1/organizations/{orgId}/seats | read:members | Seat usage (used / total / available) |
| GET | /api/v1/organizations/{orgId}/departments | read:members | List departments |
| POST | /api/v1/organizations/{orgId}/departments | write:members | Create a department |
| DELETE | /api/v1/organizations/{orgId}/departments/{deptId} | write:members | Delete a department; members stay in the organization |
| PUT / DELETE | …/departments/{deptId}/members/{userId} | write:members | Add or remove a member from a department |
| PUT / DELETE | …/members/{userId}/paths/{pathId} | write:members | Assign or unassign a learning path for one member |
| PUT / DELETE | …/departments/{deptId}/paths/{pathId} | write:members | Assign or unassign a path for a whole department |
| GET | /api/v1/organizations/{orgId}/progress | read:reports | Organization progress: per-member rows plus a rollup |
API key management
Requires an organization admin's learner token.
| Method | URL | Summary |
|---|---|---|
| GET | /api/v1/organizations/{orgId}/api-keys | List the organization's keys, including revoked ones (never key material) |
| POST | /api/v1/organizations/{orgId}/api-keys | Issue 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}/courses | write:content | Create a course |
| PUT | /api/v1/organizations/{organizationId}/courses/{courseId} | write:content | Update a course |
| POST | /api/v1/organizations/{organizationId}/courses/{courseId}/publish | write:content | Publish or unpublish a course |
| DELETE | /api/v1/organizations/{organizationId}/courses/{courseId} | write:content | Delete a course. Needs cascade=true when it has content |
| GET | /api/v1/organizations/{organizationId}/courses/{courseId}/sections | read:catalog | List sections, drafts included |
| POST | /api/v1/organizations/{organizationId}/courses/{courseId}/sections | write:content | Add a section |
| PUT | /api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId} | write:content | Update a section |
| DELETE | /api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId} | write:content | Delete a section. Needs cascade=true when it has lessons |
| GET | /api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons | read:content | List lessons, without their bodies |
| POST | /api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons | write:content | Create a lesson |
| PUT | /api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons/{lessonId} | write:content | Replace a lesson's content |
| POST | /api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons/{lessonId}/publish | write:content | Publish or unpublish a lesson |
| DELETE | /api/v1/organizations/{organizationId}/courses/{courseId}/sections/{sectionId}/lessons/{lessonId} | write:content | Delete a lesson |
| GET | /api/v1/organizations/{organizationId}/materials | read:content | List materials |
| POST | /api/v1/organizations/{organizationId}/materials | write:content | Create a material |
| PUT | /api/v1/organizations/{organizationId}/lessons/{lessonId}/materials | write:content | Attach 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:content | Fetch a test to sit — no answer key |
| POST | /api/v1/tests/{testId}/submit | write:progress | Submit answers and get the graded result |
| GET | /api/v1/me/tests | read:progress | Tests available to me, with my latest result |
| GET | /api/v1/organizations/{organizationId}/tests | read:content | List the organization's tests |
| GET | /api/v1/organizations/{organizationId}/tests/{testId} | write:content | Fetch a test with its answer key, for editing |
| POST | /api/v1/organizations/{organizationId}/tests | write:content | Create a test |
| POST | /api/v1/organizations/{organizationId}/tests/{testId}/questions | write:content | Add 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}/overview | read:reports | The organization at a glance |
| GET | /api/v1/organizations/{organizationId}/reports/completion | read:reports | Completion by course |
| GET | /api/v1/organizations/{organizationId}/reports/departments | read:reports | Completion by department |
| GET | /api/v1/organizations/{organizationId}/reports/trend | read:reports | Monthly completion trend |
| GET | /api/v1/organizations/{organizationId}/subscription | read:reports | Plan and seat position — read-only |
Search
| Method | URL | Required scope | Summary |
|---|---|---|---|
| GET | /api/v1/search?q= | read:catalog | Full-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-outline | write:content | Draft a course outline |
| POST | /api/v1/organizations/{organizationId}/ai/lesson-body | write:content | Draft a lesson body |
| POST | /api/v1/organizations/{organizationId}/ai/quiz-questions | write:content | Draft 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 seeq— 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"
}
Error codes
code |
Status | Meaning |
|---|---|---|
invalid_request | 400 | Malformed request, such as a missing required field |
invalid_credentials | 401 | Wrong email or password |
invalid_grant | 401 | The refresh token is invalid, revoked or expired |
refresh_token_reused | 401 | An already-used refresh token was presented; the whole session has been revoked |
unauthorized | 401 | No credential, or one that is not valid |
insufficient_scope | 403 | The credential lacks a scope; requiredScope names the one needed |
user_credential_required | 403 | An API key called something that requires a learner token |
admin_required | 403 | Only an organization admin may do this |
not_found | 404 | Does not exist, or is not yours |
idempotency_key_reused | 409 | This Idempotency-Key was already used for a different request — use a new one |
idempotency_key_in_progress | 409 | An identical request with this key is still being processed — retry shortly |
rate_limit_exceeded | 429 | Rate limit exceeded |
HTTP statuses
| Status | Meaning |
|---|---|
400 | Malformed request (missing required field, empty scope list) |
401 | No credential, or one that is invalid, revoked or expired |
403 | Authenticated but not permitted (missing scope, or an action that needs a learner token) |
404 | Does not exist, or is not yours |
409 | Conflict — the request clashes with something already recorded, most often an Idempotency-Key reused for a different request |
429 | Rate limit exceeded (wait the number of seconds in the Retry-After header, then retry) |
500 | Internal 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 }
}
Event types
| Event | Sent when |
|---|---|
lesson.completed | A learner completes a lesson for the first time (re-completing does not fire again) |
certificate.issued | A certificate is awarded, whether automatically or by hand |
ping | A 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));
}
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-events | List the event types you can subscribe to |
| GET | …/organizations/{orgId}/webhooks | List registered endpoints (never includes secrets) |
| POST | …/organizations/{orgId}/webhooks | Register an endpoint and receive its signing secret |
| DELETE | …/webhooks/{id} | Delete an endpoint and its delivery history |
| POST | …/webhooks/{id}/test | Send a ping to check wiring - goes only to that endpoint |
| GET | …/webhooks/{id}/deliveries | Recent deliveries with attempts, HTTP status and next retry |
| POST | …/webhooks/deliveries/{id}/replay | Re-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.
Error codes
Tool errors carry a stable string in the JSON-RPC error as data.code.
data.code | |
|---|---|
invalid_request | A missing or malformed argument. |
not_found | It does not exist, or you cannot see it — the two are deliberately not distinguished. |
forbidden | Your role in that organization is not sufficient. |
insufficient_scope | The credential lacks the required scope. |
unknown_tool | No such tool. |
user_credential_required | An 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 request | The original response, with Idempotency-Replayed: true |
| Same key, different request body or path | 409 idempotency_key_reused |
| Same key while the first call is still running | 409 idempotency_key_in_progress |
| The first call failed with a 5xx | Nothing 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.
Next: Webhook docs →