Build against the Personal Academy API
A read API over your organisation’s courses, enrolments, quiz attempts, certificates and training records, in REST and in GraphQL. Everything on this page — the version lifecycle, the rate limits, the field lists, the error codes — is read live from the running system, not written out by hand.
The Personal Academy API exposes full REST (OpenAPI 3.1 at /api/v1/openapi.json) and read-only GraphQL (/api/v1/graphql) interfaces authenticated via scoped API bearer tokens. Includes automated rate-limit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset), deterministic cursor pagination, and signed HMAC-SHA256 webhook event dispatch.
Quickstart
Every request carries an API key. Base path is /api/v1. Responses are { data, meta? } on success and { error, code, ... } on failure.
curl -sS https://<your-host>/api/v1/learn/courses?page_size=2 \
-H"Authorization: Bearer $ACADEMY_API_KEY"
# Machine-readable descriptions, no key required:
curl -sS https://<your-host>/api/v1/openapi.json # OpenAPI 3.1
curl -sS https://<your-host>/api/v1/versions # version lifecycle
curl -sS https://<your-host>/api/v1/limits # published rate limits
curl -sS https://<your-host>/api/v1/graphql/schema.graphql # GraphQL SDLA rendered browser for the OpenAPI document lives at /api/v1/docs, with a per-module view at /api/v1/docs?module=learn.
Authentication
Send Authorization: Bearer <key>. There is no cookie or session path for a machine caller. The key is hashed with SHA-256 before it is looked up; the raw value is never stored and never logged, which also means it cannot be shown to you again after it is created.
What a key can do is the intersection of three things, all checked live on every request:
- The key’s module scopes. A key scoped for
module.learncannot read assessment endpoints, whatever else it holds. - Your organisation’s entitlements. Checked independently of the key. If a module leaves your plan, calls to it stop on the next request — not when the key is next reissued.
- The live permissions of the principal the key acts as. Recomputed from that person’s current roles every time, then intersected with the permissions the key was explicitly granted. A key can never do more than its owner currently can, and never inherits everything its creator could do.
Routes that act as one learner (the /api/v1/me/* group) also need the X-Learner-Id header, set to that learner’s external_id or user_id. The learner must belong to the key’s organisation, and the header never acts as anyone holding administrator permissions.
Creating a key
An organisation administrator creates keys in Admin › Developer Portal › API Keys: give the key a name, pick its module scopes, and grant only permissions you hold yourself. The key acts as you. Its full value (pak_live_…) is shown once, when it is created; after that only its prefix is listed. Revoke a key from the same screen.
Versioning and the deprecation policy
The version is the path segment: /api/v1. Its lifecycle is not a promise on this page — it is a row the API reads on every request, published at GET /api/v1/versions.
| Version | Status | Released | Deprecated | Sunset | Replaced by |
|---|---|---|---|---|---|
| v1 | current | 2026-09-10 | — | — | — |
v1 — Cursor-paginated read API over the learn and assess modules. Additive change only: a new optional field, a new endpoint, a new filter or sort column may appear without notice. Removing or renaming a field, changing a field type or meaning, or changing what a status code means requires a new major version.
What each status does to your requests
- current — served normally. Every response carries
X-API-Version. - deprecated — still served, but every response adds RFC 8594’s
DeprecationandSunsetheaders (both HTTP-dates) andLink: <…>; rel="deprecation". You find out on calls you are already making; you do not have to be subscribed to anything. - sunset — every request answers
410 Gonewith a body namingreplacement_version,sunset_onand where the migration is documented. Not a 404, because the URL is not unknown; it is retired.
What we may change inside a version
May change without a new version
- Adding an optional field to a response.
- Adding a new endpoint or a new module namespace.
- Adding a new filter or a new sortable column.
Write your client so an unknown field is ignored rather than fatal.
Requires a new major version
- Removing or renaming a field you already receive.
- Changing a field’s type or its meaning.
- Making a previously-optional parameter required.
- Changing what an existing status code means.
Rate limits
60 requests per 60 seconds, per API key on every plan, counted in fixed windows. Counted per API key, in fixed 60-second windows. Requests refused before the limiter runs (unauthenticated, out-of-scope, unentitled or lacking the permission) do not consume the quota.
Two things move that ceiling, and only in one direction each. The limit.api_rpm entitlement raises it when your plan includes a higher one. The api.requests_per_minute setting lets your own administrator lower it — useful to protect a downstream system — but it can never raise it above what the plan includes. Call GET /api/v1/limits with your key to see the resolved figure for your organisation.
Policies by key type
Organisation keys and partner-level keys are counted under separate policies. A partner-level key acts for the partner, so its home organisation’s plan and setting do not apply to it. A policy marked pending shows the limit enforced today; its published limit has not been decided yet.
| Policy | Keys | Limit today | Status |
|---|---|---|---|
| api.v1 | Organisation keys | 60 per 60 seconds, per key (your plan can raise it) | published |
| api.v1.partner | Partner-level keys | 60 per 60 seconds, per key | pending |
| api.v1.test | Test (sandbox) keys | 60 per 60 seconds, per key | pending |
Headers on every response
| RateLimit-Limit | Requests permitted in the current window. |
| RateLimit-Remaining | Requests left in the current window. |
| RateLimit-Reset | Seconds until the window resets (a delta, so a wrong client clock does not matter). |
| RateLimit-Policy | The policy in force, as <limit>;w=<window seconds>. |
| Retry-After | Sent on 429 only. Seconds to wait before retrying. |
| X-API-Version | Which API version answered. |
The RateLimit-* headers are omitted — rather than filled with a guess — in the rare case where the limiter itself cannot be consulted. If that happens, requests are allowed through: a quota outage should not become a total outage. Authentication, scope, entitlement and permission checks do the opposite and fail closed.
Requests refused before the limiter runs (no key, wrong scope, module not entitled, permission missing) do not consume quota.
Webhook delivery
These are the values the delivery worker runs with, also published at GET /api/v1/limits under webhook_delivery.
| Attempts per event | 8 |
| First retry delay | up to 60 seconds |
| Longest delay between attempts | 60 minutes |
| Backoff | exponential with full jitter: delay before attempt n+1 is random(0, min(maxDelay, baseDelay x 2^n)) |
| After the last attempt | dead-lettered; an administrator can replay it |
| Volume limit | pending: No per-organisation webhook volume limit has been decided. |
Endpoints
Generated from the running route table. The scope and permission columns are the exact values checked on each request.
| Endpoint | Scope | Permission | GraphQL field |
|---|---|---|---|
| GET /api/v1/admin/audit-events List audit events | module.governance | audit.view | — |
| GET /api/v1/admin/automation-rules List automation rules | module.platform | settings.manage | — |
| POST /api/v1/admin/automation-rules Create an automation rule | module.platform | settings.manage | — |
| DELETE /api/v1/admin/automation-rules/{id} Delete an automation rule | module.platform | settings.manage | — |
| GET /api/v1/admin/automation-rules/{id} Get an automation rule | module.platform | settings.manage | — |
| PATCH /api/v1/admin/automation-rules/{id} Update an automation rule | module.platform | settings.manage | — |
| GET /api/v1/admin/automation-rules/{id}/runs List a rule's runs | module.platform | settings.manage | — |
| GET /api/v1/admin/channels List delivery channels | module.integrations | settings.manage | — |
| POST /api/v1/admin/channels Create a delivery channel | module.integrations | settings.manage | — |
| DELETE /api/v1/admin/channels/{id} Delete a delivery channel | module.integrations | settings.manage | — |
| GET /api/v1/admin/channels/{id} Get a delivery channel | module.integrations | settings.manage | — |
| PATCH /api/v1/admin/channels/{id} Update a delivery channel | module.integrations | settings.manage | — |
| POST /api/v1/admin/channels/{id}/test Send a test message to a channel | module.integrations | settings.manage | — |
| GET /api/v1/admin/departments List departments | module.org | settings.manage | — |
| POST /api/v1/admin/departments Create a department | module.org | settings.manage | — |
| DELETE /api/v1/admin/departments/{id} Delete a department | module.org | settings.manage | — |
| GET /api/v1/admin/departments/{id} Get a department | module.org | settings.manage | — |
| PATCH /api/v1/admin/departments/{id} Update a department | module.org | settings.manage | — |
| GET /api/v1/admin/email-templates List email templates | module.platform | settings.manage | — |
| POST /api/v1/admin/email-templates Create or replace an email template | module.platform | settings.manage | — |
| DELETE /api/v1/admin/email-templates/{kind} Reset an email template | module.platform | settings.manage | — |
| GET /api/v1/admin/email-templates/{kind} Get an email template | module.platform | settings.manage | — |
| PUT /api/v1/admin/email-templates/{kind} Set an email template | module.platform | settings.manage | — |
| POST /api/v1/admin/email-templates/{kind}/test-send Send a test email | module.platform | settings.manage | — |
| GET /api/v1/admin/groups List groups | module.org | settings.manage | — |
| POST /api/v1/admin/groups Create a group | module.org | settings.manage | — |
| DELETE /api/v1/admin/groups/{id} Delete a group | module.org | settings.manage | — |
| GET /api/v1/admin/groups/{id} Get a group | module.org | settings.manage | — |
| PATCH /api/v1/admin/groups/{id} Update a group | module.org | settings.manage | — |
| DELETE /api/v1/admin/groups/{id}/members Remove group members | module.org | settings.manage | — |
| GET /api/v1/admin/groups/{id}/members List group members | module.org | settings.manage | — |
| POST /api/v1/admin/groups/{id}/members Add group members | module.org | settings.manage | — |
| GET /api/v1/admin/legal-holds List legal holds | module.governance | audit.view | — |
| POST /api/v1/admin/legal-holds Place a legal hold | module.governance | tenant.admin | — |
| DELETE /api/v1/admin/legal-holds/{id} Release a legal hold | module.governance | tenant.admin | — |
| GET /api/v1/admin/people List people | module.org | learner.progress.tenant | — |
| GET /api/v1/admin/people/{id} Get a person | module.org | learner.progress.tenant | — |
| PATCH /api/v1/admin/people/{id} Update a person | module.org | tenant.admin | — |
| GET /api/v1/admin/report-schedules List report schedules | module.insights | report.view.tenant | — |
| POST /api/v1/admin/report-schedules Schedule a report | module.insights | tenant.admin | — |
| DELETE /api/v1/admin/report-schedules/{id} Delete a report schedule | module.insights | tenant.admin | — |
| GET /api/v1/admin/report-schedules/{id} Get a report schedule | module.insights | report.view.tenant | — |
| PATCH /api/v1/admin/report-schedules/{id} Update a report schedule | module.insights | tenant.admin | — |
| GET /api/v1/admin/reports List reports | module.insights | report.view.tenant | — |
| POST /api/v1/admin/reports/run Run a report | module.insights | report.view.tenant | — |
| GET /api/v1/admin/retention-policies List retention policies | module.governance | audit.view | — |
| DELETE /api/v1/admin/role-assignments Revoke a role | module.org | user.role.assign | — |
| GET /api/v1/admin/role-assignments List role assignments | module.org | user.role.assign | — |
| POST /api/v1/admin/role-assignments Assign a role | module.org | user.role.assign | — |
| GET /api/v1/admin/roles List roles | module.org | user.role.assign | — |
| POST /api/v1/ai/coach Ask the AI coach | module.ai | ai.use | — |
| POST /api/v1/ai/flashcards Generate flashcards | module.learn | ai.use | — |
| POST /api/v1/ai/grade Draft AI rubric scores | module.assessment | assessment.grade | — |
| POST /api/v1/ai/mentor-checkin Mentor check-in | module.ai | ai.use | — |
| POST /api/v1/ai/plan Generate a learning plan | module.paths | ai.use | — |
| POST /api/v1/ai/questions/generate Generate draft questions | module.assessment | assessment.author | — |
| POST /api/v1/ai/reading-companion Ask the reading companion | module.ai | ai.use | — |
| POST /api/v1/ai/roleplay/sessions Start a roleplay session | module.ai | ai.use | — |
| POST /api/v1/ai/roleplay/sessions/{id}/score Score a roleplay session | module.ai | ai.use | — |
| POST /api/v1/ai/roleplay/sessions/{id}/turns Send a roleplay turn | module.ai | ai.use | — |
| POST /api/v1/ai/translate Translate a lesson (draft) | module.authoring | lesson.edit | — |
| GET /api/v1/assess/assessments List assessments | module.assessment | course.view | — |
| GET /api/v1/assess/assessments/{id} Get one assessment | module.assessment | course.view | — |
| GET /api/v1/assess/attempts List quiz attempts | module.assessment | learner.progress.tenant | assessAttempts |
| POST /api/v1/assess/attempts Start or resume an attempt | module.assessment | course.view | — |
| POST /api/v1/assess/attempts/{id}/grade Grade an attempt (human, rubric) | module.assessment | assessment.grade | — |
| PUT /api/v1/assess/attempts/{id}/responses Save one answer | module.assessment | course.view | — |
| GET /api/v1/assess/attempts/{id}/result Get an attempt result | module.assessment | course.view | — |
| POST /api/v1/assess/attempts/{id}/submit Submit an attempt | module.assessment | course.view | — |
| POST /api/v1/assess/attempts/{id}/upload-url Sign a file-upload answer | module.assessment | course.view | — |
| GET /api/v1/assess/certificates List issued certificates | module.assessment | learner.progress.tenant | assessCertificates |
| GET /api/v1/assess/certificates/{id}/pdf Get a certificate's printable link | module.assessment | certificate.issue | — |
| POST /api/v1/assess/certificates/issue Issue a certificate | module.assessment | certificate.issue | — |
| GET /api/v1/assess/completion-records List training completion records | module.assessment | learner.progress.tenant | assessCompletionRecords |
| GET /api/v1/assess/grading-queue List attempts awaiting grading | module.assessment | assessment.grade | — |
| GET /api/v1/assess/question-bank List question-bank items | module.assessment | assessment.author | — |
| POST /api/v1/assess/question-bank Create a question | module.assessment | assessment.author | — |
| DELETE /api/v1/assess/question-bank/{id} Delete a question | module.assessment | assessment.author | — |
| GET /api/v1/assess/question-bank/{id} Get a question | module.assessment | assessment.author | — |
| PATCH /api/v1/assess/question-bank/{id} Edit, approve or archive a question | module.assessment | assessment.author | — |
| GET /api/v1/assess/question-bank/{id}/answer-key Read a question's answer key | module.assessment | assessment.author | — |
| PUT /api/v1/assess/question-bank/{id}/answer-key Set a question's answer key | module.assessment | assessment.author | — |
| POST /api/v1/assess/question-bank/generate Generate draft questions (AI) | module.assessment | assessment.author | — |
| GET /api/v1/assess/reaction-forms/{id} Get a reaction form | module.insights | course.view | — |
| POST /api/v1/assess/reaction-forms/{id}/submit Submit a reaction-form response | module.insights | course.view | — |
| POST /api/v1/authoring/courses Build a course (Studio pipeline) | module.authoring | course.create | — |
| POST /api/v1/authoring/courses/{id}/export Export a course | module.authoring | course.export | — |
| GET /api/v1/authoring/jobs/{id} Get a course build job | module.authoring | course.create | — |
| POST /api/v1/authoring/uploads Get a signed upload URL for a source document | module.authoring | course.create | — |
| POST /api/v1/catalogue/items/{id}/add Add a catalogue item to the organisation's library | module.integrations | course.create | — |
| GET /api/v1/catalogue/search Search the organisation's content catalogue | module.integrations | course.create | — |
| GET /api/v1/commerce/cart Read the learner's cart | module.commerce | course.view | — |
| POST /api/v1/commerce/cart Add a listing to the learner's cart | module.commerce | course.view | — |
| POST /api/v1/commerce/checkout Check out the learner's cart | module.commerce | course.view | — |
| GET /api/v1/commerce/listings List what the organisation sells | module.commerce | course.view | — |
| GET /api/v1/commerce/orders List the learner's orders | module.commerce | course.view | — |
| GET /api/v1/commerce/orders/all List all of the organisation's orders | module.commerce | tenant.admin | — |
| GET /api/v1/development/enrolment-requests List enrolment requests | module.learn | course.assign | — |
| POST /api/v1/development/enrolment-requests/{id}/approve Approve an enrolment request | module.learn | course.assign | — |
| POST /api/v1/development/enrolment-requests/{id}/deny Deny an enrolment request | module.learn | course.assign | — |
| GET /api/v1/development/learning-requests List learning requests | module.skills | course.view | — |
| POST /api/v1/development/learning-requests Submit a learning request | module.skills | course.view | — |
| PATCH /api/v1/development/learning-requests/{id} Edit a learning request | module.skills | course.view | — |
| POST /api/v1/development/learning-requests/{id}/vote Toggle an upvote | module.skills | course.view | — |
| GET /api/v1/development/observations List manager observations | module.insights | learner.progress.team | — |
| POST /api/v1/development/observations Request an observation of a learner | module.insights | learner.progress.team | — |
| POST /api/v1/development/observations/{id}/record Record an observation outcome | module.insights | learner.progress.team | — |
| POST /api/v1/development/observations/request Ask the learner's manager for an observation | module.skills | course.view | — |
| GET /api/v1/development/plans The learner's personal learning plan | module.paths | course.view | — |
| POST /api/v1/development/plans/generate Generate or refresh the learner's plan | module.paths | ai.use | — |
| POST /api/v1/embed/sessions Mint an embed session | module.learn | course.view | — |
| GET /api/v1/engage/badges The learner's badges | module.engage | course.view | — |
| GET /api/v1/engage/drip/campaigns List the learner's drip campaigns | module.engage | course.view | — |
| POST /api/v1/engage/drip/campaigns/{id}/items/{itemId}/complete Complete a drip item | module.engage | course.view | — |
| GET /api/v1/engage/drip/campaigns/{id}/next Next drip item | module.engage | course.view | — |
| GET /api/v1/engage/leaderboard Leaderboard | module.engage | course.view | — |
| GET /api/v1/engage/microlearning Microlearning feed | module.engage | course.view | — |
| GET /api/v1/engage/notification-preferences Read the learner's notification preferences | module.learn | course.view | — |
| PUT /api/v1/engage/notification-preferences Save the learner's notification preferences | module.learn | course.view | — |
| GET /api/v1/engage/notifications List the learner's notifications | module.learn | course.view | — |
| POST /api/v1/engage/notifications/{id}/read Mark a notification read | module.learn | course.view | — |
| POST /api/v1/engage/notifications/read-all Mark every notification read | module.learn | course.view | — |
| POST /api/v1/engage/nuggets/{id}/answer Answer a nugget | module.learn | course.view | — |
| GET /api/v1/engage/nuggets/daily Today's nuggets | module.learn | course.view | — |
| GET /api/v1/engage/points The learner's points | module.engage | course.view | — |
| GET /api/v1/engage/recommendations Course recommendations | module.learn | course.view | — |
| GET /api/v1/engage/streak The learner's streak | module.engage | course.view | — |
| GET /api/v1/events/registrations List session registrations | module.events | course.edit.any | eventsRegistrations |
| POST /api/v1/events/registrations Register for a session | module.events | course.view | — |
| POST /api/v1/events/registrations/{id}/cancel Cancel a session registration | module.events | course.view | — |
| GET /api/v1/events/sessions List sessions | module.events | course.view | eventsSessions |
| POST /api/v1/events/sessions/{id}/attendance Mark session attendance | module.events | course.edit.any | — |
| POST /api/v1/events/sessions/{id}/attendance/claim Claim completion for an attended session | module.events | course.view | — |
| POST /api/v1/graphql Read-only GraphQL endpoint | module.learn | course.view | — |
| GET /api/v1/hooks List this key's REST Hook subscriptions | (none — scoped to the calling key's own hooks, not a module) | settings.manage | — |
| POST /api/v1/hooks Subscribe to an event (REST Hooks) | the event's module (resolved from the request body's `event` field — see description) | settings.manage | — |
| DELETE /api/v1/hooks/{id} Unsubscribe | (none — ownership check: api_key_id = the calling key) | settings.manage | — |
| GET /api/v1/hooks/samples Sample payloads for an event | the event's module (resolved from the `event` query parameter — see description) | settings.manage | — |
| GET /api/v1/insights/at-risk List at-risk learners | module.insights | report.view.tenant | — |
| GET /api/v1/insights/completions List completion records | module.insights | report.view.tenant | insightsCompletions |
| GET /api/v1/insights/kirkpatrick Kirkpatrick evaluation | module.insights | report.view.tenant | — |
| GET /api/v1/insights/reporting/{dataset} Reporting dataset (BI pull) | module.insights | report.view.tenant | — |
| GET /api/v1/insights/roi Return on investment | module.insights | report.view.tenant | — |
| POST /api/v1/knowledge/answer Ask a question and get a cited answer | module.knowledge | ai.use | — |
| GET /api/v1/knowledge/articles List articles | module.knowledge | course.view | — |
| POST /api/v1/knowledge/articles Write an article | module.knowledge | course.view | — |
| DELETE /api/v1/knowledge/articles/{id} Delete an article | module.knowledge | course.view | — |
| GET /api/v1/knowledge/articles/{id} Get one article | module.knowledge | course.view | — |
| PATCH /api/v1/knowledge/articles/{id} Edit an article | module.knowledge | course.view | — |
| GET /api/v1/knowledge/articles/{id}/comments List comments on an article | module.knowledge | course.view | — |
| POST /api/v1/knowledge/articles/{id}/comments Comment on an article | module.knowledge | course.view | — |
| DELETE /api/v1/knowledge/articles/{id}/comments/{commentId} Delete a comment | module.knowledge | course.view | — |
| PATCH /api/v1/knowledge/articles/{id}/comments/{commentId} Edit a comment | module.knowledge | course.view | — |
| DELETE /api/v1/knowledge/articles/{id}/reactions Remove a reaction | module.knowledge | course.view | — |
| POST /api/v1/knowledge/articles/{id}/reactions Mark an article helpful or not helpful | module.knowledge | course.view | — |
| GET /api/v1/knowledge/categories List knowledge categories | module.knowledge | course.view | — |
| GET /api/v1/knowledge/search Search articles | module.knowledge | course.view | — |
| POST /api/v1/learn/anything Explain a topic from the organisation's sources | module.ai | ai.use | — |
| GET /api/v1/learn/courses List courses | module.learn | course.view | learnCourses |
| GET /api/v1/learn/courses/{id} Get one course | module.learn | course.view | learnCourse |
| GET /api/v1/learn/courses/{id}/lessons List a course's modules and lessons | module.learn | course.view | — |
| GET /api/v1/learn/courses/{id}/outline A course outline with the learner's progress | module.learn | course.view | — |
| GET /api/v1/learn/enrolments List enrolments | module.learn | learner.progress.tenant | learnEnrolments |
| POST /api/v1/learn/enrolments Enrol a person in a course | module.learn | course.assign | — |
| POST /api/v1/learn/enrolments/self Enrol the learner in a course (self-service) | module.learn | course.view | — |
| GET /api/v1/learn/lessons/{id} Get a lesson as the learner sees it | module.learn | course.view | — |
| PUT /api/v1/learn/lessons/{id}/progress Record lesson progress | module.learn | course.view | — |
| POST /api/v1/learn/lessons/{id}/quiz-answers Submit a quiz answer | module.learn | course.view | — |
| GET /api/v1/learn/scorm/{packageId}/attempt Read a learner's SCORM attempt | module.standards | course.view | — |
| POST /api/v1/learn/scorm/{packageId}/attempt Start or resume a SCORM attempt | module.standards | course.view | — |
| POST /api/v1/learn/scorm/{packageId}/track Record a SCORM call | module.standards | course.view | — |
| GET /api/v1/limits Published rate limits and query bounds | no key required | — | — |
| GET /api/v1/me/profile Read the learner's profile | module.learn | course.view | — |
| PATCH /api/v1/me/profile Update the learner's profile | module.learn | course.view | — |
| GET /api/v1/me/record The learner's learning record | module.learn | course.view | — |
| GET /api/v1/me/timeline The learner's timeline | module.learn | course.view | — |
| GET /api/v1/me/transcript The learner's transcript | module.learn | course.view | — |
| GET /api/v1/org/departments List departments | module.org | report.view.tenant | orgDepartments |
| GET /api/v1/org/groups List groups | module.org | report.view.tenant | orgGroups |
| POST /api/v1/org/groups/{id}/members Add a person to a group | module.org | settings.manage | — |
| GET /api/v1/org/users List organisation members | module.org | report.view.tenant | orgUsers |
| POST /api/v1/org/users Invite a person | module.org | user.invite | — |
| DELETE /api/v1/partner/ai-keys Remove an AI provider key | — | ||
| GET /api/v1/partner/ai-keys List the partner's AI provider keys | — | ||
| PUT /api/v1/partner/ai-keys Store or update an AI provider key | — | ||
| DELETE /api/v1/partner/content-providers Remove a provider credential | — | ||
| GET /api/v1/partner/content-providers List the partner's content provider credentials | — | ||
| PUT /api/v1/partner/content-providers Store or update a provider credential | — | ||
| GET /api/v1/partner/me The calling partner | — | ||
| POST /api/v1/partner/revenue-reports Report revenue for a month | — | ||
| GET /api/v1/partner/statements List statements | — | ||
| GET /api/v1/partner/statements/{id} Get a statement | — | ||
| GET /api/v1/partner/support/tickets List support tickets | — | ||
| POST /api/v1/partner/support/tickets Open a support ticket | — | ||
| GET /api/v1/partner/tenants List customer organisations | — | ||
| POST /api/v1/partner/tenants Provision a customer organisation | — | ||
| PATCH /api/v1/partner/tenants/{id}/branding Set a customer organisation's branding | — | ||
| POST /api/v1/partner/tenants/{id}/catalogue/items/{itemId}/add Add a catalogue item to a customer organisation's library | — | ||
| GET /api/v1/partner/tenants/{id}/catalogue/search Search a customer organisation's content catalogue | — | ||
| POST /api/v1/partner/tenants/{id}/chat/install-links Create a Slack or Teams install link for a customer | — | ||
| GET /api/v1/partner/tenants/{id}/chat/installations List chat workspaces connected to a customer | — | ||
| POST /api/v1/partner/tenants/{id}/domains Claim a custom domain | — | ||
| DELETE /api/v1/partner/tenants/{id}/integrations/content-providers Remove a customer organisation's own content-library account | — | ||
| GET /api/v1/partner/tenants/{id}/integrations/content-providers List a customer organisation's content-library accounts | — | ||
| PUT /api/v1/partner/tenants/{id}/integrations/content-providers Set a customer organisation's own content-library account | — | ||
| DELETE /api/v1/partner/tenants/{id}/integrations/hris Remove a customer organisation's HRIS connection | — | ||
| GET /api/v1/partner/tenants/{id}/integrations/hris List a customer organisation's HRIS connections | — | ||
| PUT /api/v1/partner/tenants/{id}/integrations/hris Connect or update a customer organisation's HRIS | — | ||
| POST /api/v1/partner/tenants/{id}/keys Mint a key for a customer organisation | — | ||
| DELETE /api/v1/partner/tenants/{id}/keys/{keyId} Revoke a customer organisation's key | — | ||
| POST /api/v1/partner/tenants/{id}/learners Create or find a learner | — | ||
| DELETE /api/v1/partner/tenants/{id}/learners/{external_id} Erase a learner (data subject request) | — | ||
| PATCH /api/v1/partner/tenants/{id}/learners/{external_id} Update or deactivate a learner | — | ||
| GET /api/v1/partner/tenants/{id}/products Read a customer organisation's products | — | ||
| PUT /api/v1/partner/tenants/{id}/products Set a customer organisation's products | — | ||
| GET /api/v1/partner/usage Partner usage | — | ||
| POST /api/v1/partner/wallet/topups Top up the credit wallet | — | ||
| GET /api/v1/paths/{id}/progress A learner's progress through a learning path | module.paths | course.view | — |
| GET /api/v1/paths/enrolments List learning-path enrolments | module.paths | report.view.tenant | pathsEnrolments |
| POST /api/v1/paths/enrolments/self Enrol the learner in a learning path (self-service) | module.paths | course.view | — |
| GET /api/v1/paths/paths List learning paths | module.paths | report.view.tenant | pathsPaths |
| GET /api/v1/podcasts/episodes List podcast episodes | module.learn | course.view | — |
| PUT /api/v1/podcasts/episodes/{id}/progress Save podcast progress | module.learn | course.view | — |
| GET /api/v1/podcasts/feeds List podcast feeds | module.learn | course.view | — |
| DELETE /api/v1/podcasts/feeds/{id}/subscribe Unfollow a podcast feed | module.learn | course.view | — |
| POST /api/v1/podcasts/feeds/{id}/subscribe Follow a podcast feed | module.learn | course.view | — |
| GET /api/v1/programmes List a learner's programmes | module.paths | course.view | — |
| GET /api/v1/programmes/{id} A learner's programme and its steps | module.paths | course.view | — |
| POST /api/v1/programmes/enrolments Enrol a person in a programme | module.paths | course.assign | — |
| GET /api/v1/skills/competencies List competencies | module.skills | report.view.tenant | skillsCompetencies |
| GET /api/v1/skills/gaps The learner's skill gaps | module.skills | course.view | — |
| GET /api/v1/skills/scores List learner skill scores | module.skills | report.view.tenant | skillsScores |
| GET /api/v1/skills/self-assessments Read the learner's self-assessments | module.skills | course.view | — |
| PUT /api/v1/skills/self-assessments Save the learner's self-assessments | module.skills | course.view | — |
| GET /api/v1/skills/skills List skills | module.skills | report.view.tenant | skillsCatalogue |
| GET /api/v1/social/course-discussions List course discussion threads | module.community | course.view | — |
| POST /api/v1/social/course-discussions Start or reply to a course discussion | module.community | course.view | — |
| GET /api/v1/social/feed The learner's feed | module.community | course.view | — |
| POST /api/v1/social/moderation/{postId} Moderate a post | module.community | community.moderate | — |
| GET /api/v1/social/moderation/queue Moderation queue | module.community | community.moderate | — |
| GET /api/v1/social/posts List posts | module.community | course.view | — |
| POST /api/v1/social/posts Publish a post | module.community | course.view | — |
| DELETE /api/v1/social/posts/{id} Delete a post | module.community | course.view | — |
| GET /api/v1/social/posts/{id} Get one post | module.community | course.view | — |
| PATCH /api/v1/social/posts/{id} Edit a post | module.community | course.view | — |
| GET /api/v1/social/posts/{id}/comments List comments on a post | module.community | course.view | — |
| POST /api/v1/social/posts/{id}/comments Comment on a post | module.community | course.view | — |
| DELETE /api/v1/social/posts/{id}/comments/{commentId} Delete a comment | module.community | course.view | — |
| PATCH /api/v1/social/posts/{id}/comments/{commentId} Edit a comment | module.community | course.view | — |
| DELETE /api/v1/social/posts/{id}/reactions Remove a reaction | module.community | course.view | — |
| POST /api/v1/social/posts/{id}/reactions React to a post | module.community | course.view | — |
| POST /api/v1/social/posts/{id}/report Report a post | module.community | course.view | — |
| DELETE /api/v1/social/posts/{id}/saves Unsave a post | module.community | course.view | — |
| POST /api/v1/social/posts/{id}/saves Save a post | module.community | course.view | — |
| GET /api/v1/social/spaces List spaces | module.community | course.view | — |
| GET /api/v1/social/spaces/{id} Get one space | module.community | course.view | — |
| DELETE /api/v1/social/spaces/{id}/members Leave a space, or remove a member | module.community | course.view | — |
| POST /api/v1/social/spaces/{id}/members Join a space, or add a member | module.community | course.view | — |
| GET /api/v1/versions List API versions and their lifecycle | no key required | — | — |
Full field lists, filters and sort whitelists are in the OpenAPI document: /api/v1/openapi.json (OpenAPI 3.1, JSON Schema 2020-12), or per module at /api/v1/<module>/openapi.json.
Pagination
List endpoints are cursor-paginated, not offset-paginated: under concurrent writes an offset page can skip or repeat a row, and paging deep into a large table gets slower the further you go. Neither happens here.
GET /api/v1/learn/courses?page_size=50&sort=-created_at
{"data": [ ... ],"meta": {"cursor": {"next":"eyJ2IjoiMjAyNi0wOS0xMFQxMDoyMjozM1oiLCJpZCI6Ii4uLiJ9" } }
}
# next page: pass meta.cursor.next back
GET /api/v1/learn/courses?page_size=50&sort=-created_at&cursor=eyJ2Ijoi...page_sizedefaults to 25 and is capped at 100. A larger value is silently reduced to the cap rather than refused.meta.cursor.nextisnullon the last page. Treat the cursor as opaque; its encoding is not part of the contract.sort=fieldorsort=-fieldfor descending, from each endpoint’s whitelist. An unrecognised value falls back to the default ordering instead of erroring — which rows you get never depends on it.
GraphQL
One endpoint, POST /api/v1/graphql, over exactly the same read models as REST, with the same scope and permission checks applied to each root field independently. A key scoped for one module and not another gets the half it is entitled to plus a machine-readable refusal for the half it is not — not an all-or-nothing 403.
curl -sS https://<your-host>/api/v1/graphql \
-H"Authorization: Bearer $ACADEMY_API_KEY" \
-H"Content-Type: application/json" \
-d '{"query":"{ learnCourses(first: 5, status: \"published\") { nodes { id title created_at } pageInfo { endCursor hasNextPage } } }"}'What it deliberately does not do
- No mutations or subscriptions. This API is read-only. Writes go through REST endpoints where each one can be audited on its own.
- Introspection is refused (
__schema,__type). An introspection query makes the server enumerate its own type graph on demand and tells you nothing the published schema does not. The full SDL is below and at /api/v1/graphql/schema.graphql.__typenamestill works. - No fragments, variables or directives. The schema is 17 flat row types deep; each of these is refused with an error naming it, rather than half-supported.
Query bounds
An unbounded GraphQL endpoint is a denial-of-service surface handed to the caller, so queries are bounded by cost and refused before any database query runs. The formula is published so you can compute it yourself rather than discovering the ceiling by hitting it.
| Document size | 8,000 bytes |
| Root fields per query | 10 |
| Selection depth | 5 |
| Estimated cost | 1000 |
| Cost formula | sum over root fields of (rows requested × leaf fields selected per row) + 1 |
| Rate limit | one unit per request, whatever the document contains |
Schema
Generated from the same route definitions as the REST endpoints, so a field here cannot describe a shape REST does not return. Field names are the column names (created_at, not createdAt) so the two documents read identically.
# Personal Academy GraphQL API — read-only, version 1.
#
# One POST endpoint: /api/v1/graphql
# Auth: the same Authorization: Bearer <api key> as REST, resolved by the same code,
# with the same per-module scope and per-principal permission checks applied to
# each root field independently. There is no second authorisation path.
# Limits: queries are bounded by depth and by complexity; see the errors below and the
# published limits at /api/v1/limits. One GraphQL request consumes one unit of
# the same per-key rate limit REST uses.
# Introspection (__schema, __type) is refused. This document is the schema.
# Mutations and subscriptions do not exist: this API is read-only by design, and writes
# go through the REST endpoints where each one can be audited on its own.
scalar JSON
type PageInfo {
"Opaque cursor for the next page, or null on the last page. Pass back as the after argument."
endCursor: String
hasNextPage: Boolean!
}
"""List quiz attempts. Quiz attempts across the caller's tenant, from the append-only attempts ledger. Never returns a quiz block's correct answer — this table doesn't carry one."""
type AssessAttemptsItem {
id: ID!
user_id: ID!
lesson_id: ID!
block_id: ID!
attempt_no: Int!
selected_answer: String!
is_correct: Boolean!
answered_at: String!
}
type AssessAttemptsConnection {
nodes: [AssessAttemptsItem!]!
pageInfo: PageInfo!
}
"""List issued certificates. Certificates issued across the caller's tenant. Deliberately omits verification_code — see this route's file header."""
type AssessCertificatesItem {
id: ID!
user_id: ID!
course_id: ID!
template_id: ID
learner_name: String!
issued_at: String!
}
type AssessCertificatesConnection {
nodes: [AssessCertificatesItem!]!
pageInfo: PageInfo!
}
"""List training completion records. OSHA-style training records: one immutable, snapshotted row per course completion, for the tenant."""
type AssessCompletion-recordsItem {
id: ID!
user_id: ID!
course_id: ID
course_title: String!
course_version: Int!
content_summary: JSON!
attendee_name: String!
attendee_job_title: String
trainer_id: ID
trainer_name: String
trainer_qualifications: String
score: Int
completed_at: String!
}
type AssessCompletion-recordsConnection {
nodes: [AssessCompletion-recordsItem!]!
pageInfo: PageInfo!
}
"""List session registrations. Registrations (confirmed, waitlisted or cancelled) for the caller's tenant."""
type EventsRegistrationsItem {
id: ID!
session_id: ID!
user_id: ID!
status: String!
waitlist_position: Int
registered_at: String!
cancelled_at: String
}
type EventsRegistrationsConnection {
nodes: [EventsRegistrationsItem!]!
pageInfo: PageInfo!
}
"""List sessions. Classroom and virtual sessions for the caller's tenant."""
type EventsSessionsItem {
id: ID!
course_id: ID
title: String!
description: String!
delivery_mode: String!
location: String
virtual_provider: String
trainer_id: ID
starts_at: String!
ends_at: String!
session_timezone: String!
capacity: Int!
status: String!
created_at: String!
}
type EventsSessionsConnection {
nodes: [EventsSessionsItem!]!
pageInfo: PageInfo!
}
"""List completion records. Immutable, snapshotted course-completion records for the tenant, for reporting/insights-scoped keys."""
type InsightsCompletionsItem {
id: ID!
user_id: ID!
course_id: ID
course_title: String!
score: Int
completed_at: String!
expires_at: String
recert_status: String!
}
type InsightsCompletionsConnection {
nodes: [InsightsCompletionsItem!]!
pageInfo: PageInfo!
}
"""List courses. Courses visible to the caller's tenant, newest-created first by default."""
type LearnCoursesItem {
id: ID!
title: String!
description: String
status: String!
difficulty_level: String
duration_minutes: Int
estimated_minutes: Int
created_at: String!
updated_at: String!
}
type LearnCoursesConnection {
nodes: [LearnCoursesItem!]!
pageInfo: PageInfo!
}
"""Get one course. A single course by id, scoped to the caller's tenant."""
type LearnCoursesIdItem {
id: ID!
title: String!
description: String
status: String!
difficulty_level: String
duration_minutes: Int
estimated_minutes: Int
created_at: String!
updated_at: String!
}
"""List enrolments. Enrolments across the caller's tenant. Permission: learner.progress.tenant (the existing permission key that already gates cross-learner progress reads elsewhere; this route does not invent a narrower one)."""
type LearnEnrolmentsItem {
id: ID!
user_id: ID!
course_id: ID!
enrolled_at: String!
completed_at: String
last_lesson_id: ID
}
type LearnEnrolmentsConnection {
nodes: [LearnEnrolmentsItem!]!
pageInfo: PageInfo!
}
"""List departments. Departments (and other org-unit types) for the caller's tenant, including the parent_id tree structure."""
type OrgDepartmentsItem {
id: ID!
name: String!
slug: String!
description: String
parent_id: ID
type: String!
color: String!
created_at: String!
}
type OrgDepartmentsConnection {
nodes: [OrgDepartmentsItem!]!
pageInfo: PageInfo!
}
"""List groups. Groups (static and dynamic) for the caller's tenant."""
type OrgGroupsItem {
id: ID!
name: String!
description: String!
is_dynamic: Boolean!
owner_department_id: ID
created_at: String!
}
type OrgGroupsConnection {
nodes: [OrgGroupsItem!]!
pageInfo: PageInfo!
}
"""List organisation members. Member directory for the caller's tenant: profile fields and department/manager links. No auth identity or email is included."""
type OrgUsersItem {
user_id: ID!
full_name: String!
title: String!
department_id: ID
job_title_id: ID
manager_id: ID
timezone: String
created_at: String!
updated_at: String!
}
type OrgUsersConnection {
nodes: [OrgUsersItem!]!
pageInfo: PageInfo!
}
"""List learning-path enrolments. Who is enrolled in which learning path, for the caller's tenant."""
type PathsEnrolmentsItem {
id: ID!
user_id: ID!
path_id: ID!
enrolled_at: String!
completed_at: String
due_at: String
assignment_source: String!
}
type PathsEnrolmentsConnection {
nodes: [PathsEnrolmentsItem!]!
pageInfo: PageInfo!
}
"""List learning paths. Learning paths (ordered course sequences) for the caller's tenant."""
type PathsPathsItem {
id: ID!
title: String!
description: String
slug: String
is_public: Boolean!
thumbnail_url: String
created_at: String!
updated_at: String!
}
type PathsPathsConnection {
nodes: [PathsPathsItem!]!
pageInfo: PageInfo!
}
"""List competencies. The competency framework for the caller's tenant (max_level and category per competency)."""
type SkillsCompetenciesItem {
id: ID!
name: String!
description: String!
category: String!
max_level: Int!
created_at: String!
updated_at: String!
}
type SkillsCompetenciesConnection {
nodes: [SkillsCompetenciesItem!]!
pageInfo: PageInfo!
}
"""List learner skill scores. Per-learner, per-skill scores for the caller's tenant."""
type SkillsScoresItem {
id: ID!
user_id: ID!
skill_id: ID!
score: Int!
updated_at: String!
}
type SkillsScoresConnection {
nodes: [SkillsScoresItem!]!
pageInfo: PageInfo!
}
"""List skills. The skill catalogue for the caller's tenant."""
type SkillsSkillsItem {
id: ID!
name: String!
description: String
parent_skill_id: ID
external_source: String
external_id: String
created_at: String!
}
type SkillsSkillsConnection {
nodes: [SkillsSkillsItem!]!
pageInfo: PageInfo!
}
type Query {
"List quiz attempts. Requires scope module.assessment and permission learner.progress.tenant."
assessAttempts(first: Int, after: String, sort: String, lesson_id: String, user_id: String, correct: Boolean): AssessAttemptsConnection
"List issued certificates. Requires scope module.assessment and permission learner.progress.tenant."
assessCertificates(first: Int, after: String, sort: String, course_id: String, user_id: String): AssessCertificatesConnection
"List training completion records. Requires scope module.assessment and permission learner.progress.tenant."
assessCompletionRecords(first: Int, after: String, sort: String, course_id: String, user_id: String): AssessCompletion-recordsConnection
"List session registrations. Requires scope module.events and permission course.edit.any."
eventsRegistrations(first: Int, after: String, sort: String, session_id: String, user_id: String, status: String): EventsRegistrationsConnection
"List sessions. Requires scope module.events and permission course.view."
eventsSessions(first: Int, after: String, sort: String, status: String, course_id: String, search: String): EventsSessionsConnection
"List completion records. Requires scope module.insights and permission report.view.tenant."
insightsCompletions(first: Int, after: String, sort: String, course_id: String, user_id: String): InsightsCompletionsConnection
"List courses. Requires scope module.learn and permission course.view."
learnCourses(first: Int, after: String, sort: String, status: String, search: String): LearnCoursesConnection
"Get one course. Requires scope module.learn and permission course.view."
learnCourse(id: ID!): LearnCoursesIdItem
"List enrolments. Requires scope module.learn and permission learner.progress.tenant."
learnEnrolments(first: Int, after: String, sort: String, course_id: String, completed: Boolean): LearnEnrolmentsConnection
"List departments. Requires scope module.org and permission report.view.tenant."
orgDepartments(first: Int, after: String, sort: String, search: String, type: String): OrgDepartmentsConnection
"List groups. Requires scope module.org and permission report.view.tenant."
orgGroups(first: Int, after: String, sort: String, search: String, is_dynamic: Boolean): OrgGroupsConnection
"List organisation members. Requires scope module.org and permission report.view.tenant."
orgUsers(first: Int, after: String, sort: String, department_id: String, search: String): OrgUsersConnection
"List learning-path enrolments. Requires scope module.paths and permission report.view.tenant."
pathsEnrolments(first: Int, after: String, sort: String, path_id: String, user_id: String, completed: String): PathsEnrolmentsConnection
"List learning paths. Requires scope module.paths and permission report.view.tenant."
pathsPaths(first: Int, after: String, sort: String, search: String, is_public: String): PathsPathsConnection
"List competencies. Requires scope module.skills and permission report.view.tenant."
skillsCompetencies(first: Int, after: String, sort: String, category: String, search: String): SkillsCompetenciesConnection
"List learner skill scores. Requires scope module.skills and permission report.view.tenant."
skillsScores(first: Int, after: String, sort: String, user_id: String, skill_id: String): SkillsScoresConnection
"List skills. Requires scope module.skills and permission report.view.tenant."
skillsCatalogue(first: Int, after: String, sort: String, search: String): SkillsSkillsConnection
}
schema {
query: Query
}
Error catalogue
Every non-2xx response is {"error":"...","code":"..." }, sometimes with extra fields. code is what you branch on; error is for a human reading a log. Over GraphQL the same codes arrive in errors[].extensions.code.
| Status | Code | What happened / what to do | Retry? |
|---|---|---|---|
| 400 | bad_request | The request was malformed — an unparseable body, or a parameter whose value is not of the shape the endpoint accepts. Fix the request. Retrying it unchanged will fail identically. Extra fields: issues | no |
| 413 | payload_too_large | The request body is larger than the endpoint accepts (1 MB unless the endpoint documents otherwise). The body was not read past the limit. Send a smaller body. Retrying it unchanged will fail identically. | no |
| 401 | unauthorized | No `Authorization: Bearer <key>` header, or a key that does not resolve — unknown, revoked, or past its expiry. Send a live key. If it was working yesterday, check whether it was revoked or has expired. | no |
| 401 | key_revoked | The key exists but was switched off by deprovisioning: the person it acts as has been deactivated in the organisation (which revokes every key acting as them), or the organisation itself is suspended or deleted. A key revoked by hand answers `unauthorized`. Mint a new key acting as an active member. A key of a suspended organisation works again if the organisation is reinstated; a key revoked by deactivation does not. | no |
| 403 | scope_not_granted | The key resolved, but it is not scoped for the module this endpoint belongs to. Mint a key that includes the module scope named in the `scope` field, or add the scope to this one. Extra fields: scope | no |
| 403 | module_not_entitled | The organisation's plan does not currently include this module. Checked live on every request against entitlements, never against a flag stored on the key — so a plan change takes effect on the next call, not when the key is next reissued. The organisation needs the module on its plan. Nothing about the key can fix this. Extra fields: module | no |
| 403 | permission_denied | The principal the key acts as does not hold the permission this endpoint requires. Recomputed from that principal's current roles on every request — a key can never outlive its owner's authority. Grant the named permission to the acting principal, or use a key acting as a principal that already holds it. Extra fields: required_permission | no |
| 404 | not_found | No resource with that id in this organisation. A resource that exists but belongs to a different organisation is reported this way too, deliberately: a 403 would confirm the id exists. Check the id. Do not treat 404 as proof the id is unused globally. | no |
| 404 | route_not_found | No endpoint exists at this method and path under /api/v1. The path or the HTTP method is wrong; this is a statement about the URL, not about any resource. Check the path against the OpenAPI document (GET /api/v1/openapi.json) and the HTTP method. | no |
| 410 | version_sunset | This API version has been retired and no longer answers. The body names the version that replaced it and the date this one stopped. Move to the version named in `replacement_version`. This will never start working again. Extra fields: version, sunset_on, replacement_version, documentation_url | no |
| 429 | rate_limited | Too many requests for this API key in the current window. Requests refused earlier in the chain (unauthenticated, out of scope, unentitled, lacking the permission) do not consume quota. Wait `retry_after_seconds`, or read `RateLimit-Reset` on any response to pace yourself before you hit this. Extra fields: retry_after_seconds | yes |
| 500 | internal_error | Something failed on our side. The response body never carries the underlying detail. Retry with backoff. If it persists, quote the time of the call when you report it. | yes |
| 503 | api_versions_not_provisioned | The version catalogue could not be read, so the API cannot tell you which versions exist or whether any is retiring. Deliberately distinct from an empty list: "nothing is deprecated" and "I cannot tell you" are different answers, and only one of them is safe to build against. Nothing a caller can do. On a self-hosted install, apply the outstanding migrations. | yes |
| 503 | api_keys_not_provisioned | API key infrastructure is not present on this server. This is a deployment state, not a fact about your key — no request can succeed until it is fixed. Nothing a caller can do. On a self-hosted install, apply the outstanding migrations. | yes |
| 503 | feature_not_provisioned | This specific endpoint depends on a database function or table that has not been applied to this server yet. Distinct from api_keys_not_provisioned: your key resolved fine — it is this one endpoint's own migration that is outstanding. Nothing a caller can do. On a self-hosted install, apply the migration named in the response (see the project's "SQL needed" report for this endpoint). | yes |
| 403 | partner_scope_required | This endpoint belongs to the Partner API. The key is not a partner key, or it does not carry the `partner.*` scope the endpoint needs. A key minted for a customer organisation can never carry a partner scope. Use a partner key that includes the scope named in the `scope` field. Extra fields: scope | no |
| 403 | partner_role_insufficient | The person this partner key acts as holds a partner role below the one this endpoint needs. Recomputed from live partner membership on every request. Ask a partner owner to raise the person's role, or use a key acting as someone who already holds it. Extra fields: required_role | no |
| 403 | partner_suspended | The partner is suspended. Every Partner API route refuses while it is; customer organisations' own keys are unaffected. Contact the platform operator. | no |
| 404 | tenant_not_child | No customer organisation with that id belongs to this partner. An organisation owned by another partner is reported the same way, so the answer never confirms that an id exists. Check the id against GET /api/v1/partner/tenants. | no |
| 403 | partner_child_limit | The partner contract's `max_child_tenants` is reached. Ask the platform operator to raise the contract ceiling. | no |
| 422 | wrong_region | The partner is served from a different region than this deployment, so its customer organisations cannot be created or changed here. Call the region the partner directory names for this partner (GET /api/v1/partner/me). | no |
| 403 | module_outside_contract | The tier or scope requested includes a module the partner contract does not allow the partner to sell. Choose a tier or scopes inside the contract, or ask the platform operator to widen it. | no |
| 409 | partner_tier_above_contract | The tier requested is above the tier ceiling in the partner contract. Choose a lower tier, or ask the platform operator to raise the ceiling. | no |
| 422 | partner_tier_free | A customer organisation is never on the free tier. Choose a paid tier, or omit `tier` for the contract default. | no |
| 422 | trial_on_partner_child | A customer organisation has no trial: it starts on the tier the partner chose. Choose a paid tier, or omit `tier` for the contract default. | no |
| 422 | unknown_tier | The tier named is not a tier of the current price list. Use a tier key from the pricing page (for example basic, advanced or premium), or omit `tier`. | no |
| 409 | partner_exiting | The partner has given notice to leave the programme, so it cannot add customer organisations. Nothing to retry. Existing customers choose to become direct customers or to export. | no |
| 400 | learner_header_required | This endpoint acts as a learner and needs the `X-Learner-Id` header (an external_id or a user_id). Send the header. | no |
| 404 | learner_not_in_tenant | No active learner with that identifier exists in this organisation. A learner of another organisation is reported the same way. Create the learner first (POST /api/v1/partner/tenants/{id}/learners), or check the identifier. | no |
| 403 | origin_not_allowed | The `origin` is not one this organisation has allowed to frame its pages. It must be an exact origin (scheme, host and port) listed in the partner's `embed_frame_ancestors` or in the organisation's `embed.allowed_origins` setting. Add the page's origin to the allow-list, or send the origin that actually frames the embed. | no |
| 403 | target_not_embeddable | The `target` is not a route on the embed allow-list (account security, administration and anything else that was never built to sit inside another site's page are refused). Choose a target from the embeddable routes in the embed documentation. | no |
| 422 | email_not_allowed | An email address was sent for a learner of an organisation whose contract is `identity_mode: pseudonymous`. Omit `email`; the learner is identified by `external_id` alone. | no |
| 409 | email_in_use | That email address already belongs to an account outside this organisation. Use a different address, or omit it. | no |
| 402 | learner_cap | The partner contract's `max_learners_per_tenant` is reached for this organisation. Deactivate learners, or ask the platform operator to raise the contract ceiling. | no |
| 402 | learner_limit_reached | The organisation's plan seat limit is reached. Move the organisation to a plan with more seats. | no |
| 402 | limit_reached | A limit of the organisation's plan refuses this request (for example the Free member limit). Read `upgrade_to` for the lowest plan tier that allows it, or free up room (remove a member, revoke a pending invitation). Extra fields: limit, value, used, upgrade_to | no |
| 409 | legal_hold | The learner is under a legal hold, so the erasure request was refused and nothing was deleted. Wait for the hold to be lifted, then retry. | no |
| 409 | conflict | The request conflicts with the current state: a subdomain or hostname that is already claimed, or an organisation that cannot take the change. Choose another value, or read the message for the specific conflict. | no |
| 422 | validation_failed | The request is well-formed but a value is not acceptable (for example an `external_id` outside 1 to 200 characters). Fix the value named in the message. | no |
| 429 | ai_quota_exceeded | The AI allowance was used up: the per-minute rate, the learner's daily allowance, the organisation's monthly AI budget, or the platform-wide pause. The same quota the signed-in product applies. Wait `retry_after_seconds` when it is present; otherwise the allowance resets at the start of the next day or month. Extra fields: retry_after_seconds, reason | yes |
| 402 | allowance_exhausted | The caller has used their weekly AI allowance in this organisation (PRC-26: a weekly limit per person by class, with group and person overrides). The organisation wallet may still have credits. No credits or prices are given (Q24). Wait for the weekly reset (Monday 00:00 UTC, `resets_at`), or ask for more from `request_url` (an approver can raise the limit for a week). Extra fields: resets_at, request_url | no |
| 402 | insufficient_credits | The AI credit balance that pays for this call cannot cover it. For an organisation that buys its own credits the body carries `buy_url`; for an organisation whose credits come from its provider (a partner wallet) there is no `buy_url` and the provider must top up. Top up the credit balance that pays for the organisation, then retry. Extra fields: balance, needed, buy_url | no |
| 503 | guardrails_missing | The locked safety guardrails for this AI activity could not be loaded, so the call was refused before any model was reached. The gateway fails closed. Nothing a caller can do. Retry later; if it persists, report it to the platform operator. | yes |
| 501 | format_unavailable | The requested export format is not available on this deployment (for example parquet for BI export). Ask for csv or json instead. | no |
| 403 | feature_disabled | This AI feature is switched off for the organisation (its AI opt-out) or is not registered. Read live on every call, so an opt-out takes effect on the next request. An organisation administrator must switch the feature on. Extra fields: activity_key | no |
| 502 | ai_output_invalid | The model's answer failed validation (malformed or cut off), so nothing was stored and no usable result exists. Credits are not charged for a call that produced no usable result. Retry the request. A shorter input often helps. | yes |
| 409 | completion_required | A certificate can only be issued to a learner who has completed the course; no completion exists for this learner and course. Record or wait for the completion, then retry. | no |
| 409 | idempotency_conflict | The `Idempotency-Key` was already used for a different request body or path, or the first request with this key is still being processed. Use a new key for new work; wait and retry with the same key if the first request is still running. | no |
| 403 | lesson_locked | The learner cannot open this lesson: their rental has expired or their pay-per-view allowance is used up. The same condition the course page shows. Renew the learner's access, then retry. Extra fields: reason, course_id | no |
| 409 | proctored_requires_top_level | This assessment is proctored (camera, fullscreen, focus rules), so it cannot be taken headless or inside a frame. Create an embed session for the assessment and open it in its own browser tab. Extra fields: hint | no |
| 413 | too_large | The upload is larger than the 10 MB limit for assessment answers. Upload a smaller file. | no |
| 409 | topups_not_applicable | This partner is billed for credits on its monthly statement (postpaid), so prepaid wallet top-ups do not apply. Nothing to do: credits used appear on the next statement. | no |
| 409 | payment_account_required | This organisation has no payment account connected, so it cannot take payments. A partner's customer organisation never uses the platform's payment account. An organisation administrator connects a Razorpay or Stripe account in Commerce settings. | no |
| 502 | payment_provider_error | The payment provider refused to create the checkout. The order exists and is pending. Retry shortly; if it persists, check the payment account's keys and mode. | yes |
| 403 | partner_subscription_not_available | Module subscriptions cannot be sold inside a partner's customer organisation; its modules come from the partner's contract. Ask the partner to change the organisation's plan. | no |
| 403 | ai_refused | The AI gateway refused the call for a reason not covered by a more specific code (for example a module or permission the learner lacks, or a budget rule). Read `reason` for the gateway's own refusal code. Extra fields: reason | no |
| 403 | product_off | The product this endpoint belongs to is switched off for this organisation by its partner. Checked live on every request, so a switch takes effect on the next call. The product's data is kept, read-only, and comes back when the product is switched on again. The partner switches the product on for the organisation (PUT /api/v1/partner/tenants/{id}/products or the portal's Products page). Extra fields: product, module | no |
| 422 | unknown_product | A product key in `products` is not a product of the catalogue. Use the product keys listed on the portal's Products page and the pricing page. Extra fields: products | no |
| 422 | product_requires | A product in `products` needs another product that is not in the set. Add the product named in the message to the set, or leave out the product that needs it. | no |
| 403 | product_outside_contract | A product in `products` includes a module the partner contract does not allow the partner to sell. Leave the product out, or ask the platform operator to widen the contract. | no |
| 422 | products_required | No `products` were named and the partner has no partner-wide default products to apply. Send `products`, or set the partner-wide default products first. | no |
| 403 | sandbox_customer_cap | The sandbox already has the most test customers it may have (the portal's Go live page shows the limits). Nothing was charged; a sandbox is never billed. Delete a test customer you no longer need, or go live to create live customers. | no |
| 403 | sandbox_user_cap | The sandbox already has the most test users it may have across its test customers. Nothing was charged. Deactivate a test user you no longer need, then retry. | no |
| 403 | sandbox_credit_cap | The sandbox has used its test credits for this month. Test credits are never billed and never come from the partner wallet. Wait for the allowance to renew on the 1st (UTC). | no |
| 403 | test_key_required | A sandbox partner and its test customers use test keys (`pak_test_...`) only. Mint a test key, or request go-live for live keys. | no |
| 403 | test_key_on_live_tenant | A test key (`pak_test_...`) reaches only test (sandbox) customers, never a live organisation. Use a live key for a live customer organisation. | no |
| 409 | test_customer_immutable | Whether an organisation is a test customer is fixed when it is created. Create a new organisation of the kind you need. | no |
A 404 on a single-resource read means “no such id in your organisation”. A record that exists but belongs to someone else is reported the same way, on purpose: a 403 would confirm the id exists.