API Reference

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.

Quick Answer · Developer Integration Architecture

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 SDL

A 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:

  1. The key’s module scopes. A key scoped for module.learn cannot read assessment endpoints, whatever else it holds.
  2. 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.
  3. 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.

VersionStatusReleasedDeprecatedSunsetReplaced by
v1current2026-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 Deprecation and Sunset headers (both HTTP-dates) and Link: <…>; 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 Gone with a body naming replacement_version, sunset_on and 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.

PolicyKeysLimit todayStatus
api.v1Organisation keys60 per 60 seconds, per key (your plan can raise it)published
api.v1.partnerPartner-level keys60 per 60 seconds, per keypending
api.v1.testTest (sandbox) keys60 per 60 seconds, per keypending

Headers on every response

RateLimit-LimitRequests permitted in the current window.
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds until the window resets (a delta, so a wrong client clock does not matter).
RateLimit-PolicyThe policy in force, as <limit>;w=<window seconds>.
Retry-AfterSent on 429 only. Seconds to wait before retrying.
X-API-VersionWhich 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 event8
First retry delayup to 60 seconds
Longest delay between attempts60 minutes
Backoffexponential with full jitter: delay before attempt n+1 is random(0, min(maxDelay, baseDelay x 2^n))
After the last attemptdead-lettered; an administrator can replay it
Volume limitpending: 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.

EndpointScopePermissionGraphQL field
GET /api/v1/admin/audit-events

List audit events

module.governanceaudit.view—
GET /api/v1/admin/automation-rules

List automation rules

module.platformsettings.manage—
POST /api/v1/admin/automation-rules

Create an automation rule

module.platformsettings.manage—
DELETE /api/v1/admin/automation-rules/{id}

Delete an automation rule

module.platformsettings.manage—
GET /api/v1/admin/automation-rules/{id}

Get an automation rule

module.platformsettings.manage—
PATCH /api/v1/admin/automation-rules/{id}

Update an automation rule

module.platformsettings.manage—
GET /api/v1/admin/automation-rules/{id}/runs

List a rule's runs

module.platformsettings.manage—
GET /api/v1/admin/channels

List delivery channels

module.integrationssettings.manage—
POST /api/v1/admin/channels

Create a delivery channel

module.integrationssettings.manage—
DELETE /api/v1/admin/channels/{id}

Delete a delivery channel

module.integrationssettings.manage—
GET /api/v1/admin/channels/{id}

Get a delivery channel

module.integrationssettings.manage—
PATCH /api/v1/admin/channels/{id}

Update a delivery channel

module.integrationssettings.manage—
POST /api/v1/admin/channels/{id}/test

Send a test message to a channel

module.integrationssettings.manage—
GET /api/v1/admin/departments

List departments

module.orgsettings.manage—
POST /api/v1/admin/departments

Create a department

module.orgsettings.manage—
DELETE /api/v1/admin/departments/{id}

Delete a department

module.orgsettings.manage—
GET /api/v1/admin/departments/{id}

Get a department

module.orgsettings.manage—
PATCH /api/v1/admin/departments/{id}

Update a department

module.orgsettings.manage—
GET /api/v1/admin/email-templates

List email templates

module.platformsettings.manage—
POST /api/v1/admin/email-templates

Create or replace an email template

module.platformsettings.manage—
DELETE /api/v1/admin/email-templates/{kind}

Reset an email template

module.platformsettings.manage—
GET /api/v1/admin/email-templates/{kind}

Get an email template

module.platformsettings.manage—
PUT /api/v1/admin/email-templates/{kind}

Set an email template

module.platformsettings.manage—
POST /api/v1/admin/email-templates/{kind}/test-send

Send a test email

module.platformsettings.manage—
GET /api/v1/admin/groups

List groups

module.orgsettings.manage—
POST /api/v1/admin/groups

Create a group

module.orgsettings.manage—
DELETE /api/v1/admin/groups/{id}

Delete a group

module.orgsettings.manage—
GET /api/v1/admin/groups/{id}

Get a group

module.orgsettings.manage—
PATCH /api/v1/admin/groups/{id}

Update a group

module.orgsettings.manage—
DELETE /api/v1/admin/groups/{id}/members

Remove group members

module.orgsettings.manage—
GET /api/v1/admin/groups/{id}/members

List group members

module.orgsettings.manage—
POST /api/v1/admin/groups/{id}/members

Add group members

module.orgsettings.manage—
GET /api/v1/admin/legal-holds

List legal holds

module.governanceaudit.view—
POST /api/v1/admin/legal-holds

Place a legal hold

module.governancetenant.admin—
DELETE /api/v1/admin/legal-holds/{id}

Release a legal hold

module.governancetenant.admin—
GET /api/v1/admin/people

List people

module.orglearner.progress.tenant—
GET /api/v1/admin/people/{id}

Get a person

module.orglearner.progress.tenant—
PATCH /api/v1/admin/people/{id}

Update a person

module.orgtenant.admin—
GET /api/v1/admin/report-schedules

List report schedules

module.insightsreport.view.tenant—
POST /api/v1/admin/report-schedules

Schedule a report

module.insightstenant.admin—
DELETE /api/v1/admin/report-schedules/{id}

Delete a report schedule

module.insightstenant.admin—
GET /api/v1/admin/report-schedules/{id}

Get a report schedule

module.insightsreport.view.tenant—
PATCH /api/v1/admin/report-schedules/{id}

Update a report schedule

module.insightstenant.admin—
GET /api/v1/admin/reports

List reports

module.insightsreport.view.tenant—
POST /api/v1/admin/reports/run

Run a report

module.insightsreport.view.tenant—
GET /api/v1/admin/retention-policies

List retention policies

module.governanceaudit.view—
DELETE /api/v1/admin/role-assignments

Revoke a role

module.orguser.role.assign—
GET /api/v1/admin/role-assignments

List role assignments

module.orguser.role.assign—
POST /api/v1/admin/role-assignments

Assign a role

module.orguser.role.assign—
GET /api/v1/admin/roles

List roles

module.orguser.role.assign—
POST /api/v1/ai/coach

Ask the AI coach

module.aiai.use—
POST /api/v1/ai/flashcards

Generate flashcards

module.learnai.use—
POST /api/v1/ai/grade

Draft AI rubric scores

module.assessmentassessment.grade—
POST /api/v1/ai/mentor-checkin

Mentor check-in

module.aiai.use—
POST /api/v1/ai/plan

Generate a learning plan

module.pathsai.use—
POST /api/v1/ai/questions/generate

Generate draft questions

module.assessmentassessment.author—
POST /api/v1/ai/reading-companion

Ask the reading companion

module.aiai.use—
POST /api/v1/ai/roleplay/sessions

Start a roleplay session

module.aiai.use—
POST /api/v1/ai/roleplay/sessions/{id}/score

Score a roleplay session

module.aiai.use—
POST /api/v1/ai/roleplay/sessions/{id}/turns

Send a roleplay turn

module.aiai.use—
POST /api/v1/ai/translate

Translate a lesson (draft)

module.authoringlesson.edit—
GET /api/v1/assess/assessments

List assessments

module.assessmentcourse.view—
GET /api/v1/assess/assessments/{id}

Get one assessment

module.assessmentcourse.view—
GET /api/v1/assess/attempts

List quiz attempts

module.assessmentlearner.progress.tenantassessAttempts
POST /api/v1/assess/attempts

Start or resume an attempt

module.assessmentcourse.view—
POST /api/v1/assess/attempts/{id}/grade

Grade an attempt (human, rubric)

module.assessmentassessment.grade—
PUT /api/v1/assess/attempts/{id}/responses

Save one answer

module.assessmentcourse.view—
GET /api/v1/assess/attempts/{id}/result

Get an attempt result

module.assessmentcourse.view—
POST /api/v1/assess/attempts/{id}/submit

Submit an attempt

module.assessmentcourse.view—
POST /api/v1/assess/attempts/{id}/upload-url

Sign a file-upload answer

module.assessmentcourse.view—
GET /api/v1/assess/certificates

List issued certificates

module.assessmentlearner.progress.tenantassessCertificates
GET /api/v1/assess/certificates/{id}/pdf

Get a certificate's printable link

module.assessmentcertificate.issue—
POST /api/v1/assess/certificates/issue

Issue a certificate

module.assessmentcertificate.issue—
GET /api/v1/assess/completion-records

List training completion records

module.assessmentlearner.progress.tenantassessCompletionRecords
GET /api/v1/assess/grading-queue

List attempts awaiting grading

module.assessmentassessment.grade—
GET /api/v1/assess/question-bank

List question-bank items

module.assessmentassessment.author—
POST /api/v1/assess/question-bank

Create a question

module.assessmentassessment.author—
DELETE /api/v1/assess/question-bank/{id}

Delete a question

module.assessmentassessment.author—
GET /api/v1/assess/question-bank/{id}

Get a question

module.assessmentassessment.author—
PATCH /api/v1/assess/question-bank/{id}

Edit, approve or archive a question

module.assessmentassessment.author—
GET /api/v1/assess/question-bank/{id}/answer-key

Read a question's answer key

module.assessmentassessment.author—
PUT /api/v1/assess/question-bank/{id}/answer-key

Set a question's answer key

module.assessmentassessment.author—
POST /api/v1/assess/question-bank/generate

Generate draft questions (AI)

module.assessmentassessment.author—
GET /api/v1/assess/reaction-forms/{id}

Get a reaction form

module.insightscourse.view—
POST /api/v1/assess/reaction-forms/{id}/submit

Submit a reaction-form response

module.insightscourse.view—
POST /api/v1/authoring/courses

Build a course (Studio pipeline)

module.authoringcourse.create—
POST /api/v1/authoring/courses/{id}/export

Export a course

module.authoringcourse.export—
GET /api/v1/authoring/jobs/{id}

Get a course build job

module.authoringcourse.create—
POST /api/v1/authoring/uploads

Get a signed upload URL for a source document

module.authoringcourse.create—
POST /api/v1/catalogue/items/{id}/add

Add a catalogue item to the organisation's library

module.integrationscourse.create—
GET /api/v1/catalogue/search

Search the organisation's content catalogue

module.integrationscourse.create—
GET /api/v1/commerce/cart

Read the learner's cart

module.commercecourse.view—
POST /api/v1/commerce/cart

Add a listing to the learner's cart

module.commercecourse.view—
POST /api/v1/commerce/checkout

Check out the learner's cart

module.commercecourse.view—
GET /api/v1/commerce/listings

List what the organisation sells

module.commercecourse.view—
GET /api/v1/commerce/orders

List the learner's orders

module.commercecourse.view—
GET /api/v1/commerce/orders/all

List all of the organisation's orders

module.commercetenant.admin—
GET /api/v1/development/enrolment-requests

List enrolment requests

module.learncourse.assign—
POST /api/v1/development/enrolment-requests/{id}/approve

Approve an enrolment request

module.learncourse.assign—
POST /api/v1/development/enrolment-requests/{id}/deny

Deny an enrolment request

module.learncourse.assign—
GET /api/v1/development/learning-requests

List learning requests

module.skillscourse.view—
POST /api/v1/development/learning-requests

Submit a learning request

module.skillscourse.view—
PATCH /api/v1/development/learning-requests/{id}

Edit a learning request

module.skillscourse.view—
POST /api/v1/development/learning-requests/{id}/vote

Toggle an upvote

module.skillscourse.view—
GET /api/v1/development/observations

List manager observations

module.insightslearner.progress.team—
POST /api/v1/development/observations

Request an observation of a learner

module.insightslearner.progress.team—
POST /api/v1/development/observations/{id}/record

Record an observation outcome

module.insightslearner.progress.team—
POST /api/v1/development/observations/request

Ask the learner's manager for an observation

module.skillscourse.view—
GET /api/v1/development/plans

The learner's personal learning plan

module.pathscourse.view—
POST /api/v1/development/plans/generate

Generate or refresh the learner's plan

module.pathsai.use—
POST /api/v1/embed/sessions

Mint an embed session

module.learncourse.view—
GET /api/v1/engage/badges

The learner's badges

module.engagecourse.view—
GET /api/v1/engage/drip/campaigns

List the learner's drip campaigns

module.engagecourse.view—
POST /api/v1/engage/drip/campaigns/{id}/items/{itemId}/complete

Complete a drip item

module.engagecourse.view—
GET /api/v1/engage/drip/campaigns/{id}/next

Next drip item

module.engagecourse.view—
GET /api/v1/engage/leaderboard

Leaderboard

module.engagecourse.view—
GET /api/v1/engage/microlearning

Microlearning feed

module.engagecourse.view—
GET /api/v1/engage/notification-preferences

Read the learner's notification preferences

module.learncourse.view—
PUT /api/v1/engage/notification-preferences

Save the learner's notification preferences

module.learncourse.view—
GET /api/v1/engage/notifications

List the learner's notifications

module.learncourse.view—
POST /api/v1/engage/notifications/{id}/read

Mark a notification read

module.learncourse.view—
POST /api/v1/engage/notifications/read-all

Mark every notification read

module.learncourse.view—
POST /api/v1/engage/nuggets/{id}/answer

Answer a nugget

module.learncourse.view—
GET /api/v1/engage/nuggets/daily

Today's nuggets

module.learncourse.view—
GET /api/v1/engage/points

The learner's points

module.engagecourse.view—
GET /api/v1/engage/recommendations

Course recommendations

module.learncourse.view—
GET /api/v1/engage/streak

The learner's streak

module.engagecourse.view—
GET /api/v1/events/registrations

List session registrations

module.eventscourse.edit.anyeventsRegistrations
POST /api/v1/events/registrations

Register for a session

module.eventscourse.view—
POST /api/v1/events/registrations/{id}/cancel

Cancel a session registration

module.eventscourse.view—
GET /api/v1/events/sessions

List sessions

module.eventscourse.vieweventsSessions
POST /api/v1/events/sessions/{id}/attendance

Mark session attendance

module.eventscourse.edit.any—
POST /api/v1/events/sessions/{id}/attendance/claim

Claim completion for an attended session

module.eventscourse.view—
POST /api/v1/graphql

Read-only GraphQL endpoint

module.learncourse.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.insightsreport.view.tenant—
GET /api/v1/insights/completions

List completion records

module.insightsreport.view.tenantinsightsCompletions
GET /api/v1/insights/kirkpatrick

Kirkpatrick evaluation

module.insightsreport.view.tenant—
GET /api/v1/insights/reporting/{dataset}

Reporting dataset (BI pull)

module.insightsreport.view.tenant—
GET /api/v1/insights/roi

Return on investment

module.insightsreport.view.tenant—
POST /api/v1/knowledge/answer

Ask a question and get a cited answer

module.knowledgeai.use—
GET /api/v1/knowledge/articles

List articles

module.knowledgecourse.view—
POST /api/v1/knowledge/articles

Write an article

module.knowledgecourse.view—
DELETE /api/v1/knowledge/articles/{id}

Delete an article

module.knowledgecourse.view—
GET /api/v1/knowledge/articles/{id}

Get one article

module.knowledgecourse.view—
PATCH /api/v1/knowledge/articles/{id}

Edit an article

module.knowledgecourse.view—
GET /api/v1/knowledge/articles/{id}/comments

List comments on an article

module.knowledgecourse.view—
POST /api/v1/knowledge/articles/{id}/comments

Comment on an article

module.knowledgecourse.view—
DELETE /api/v1/knowledge/articles/{id}/comments/{commentId}

Delete a comment

module.knowledgecourse.view—
PATCH /api/v1/knowledge/articles/{id}/comments/{commentId}

Edit a comment

module.knowledgecourse.view—
DELETE /api/v1/knowledge/articles/{id}/reactions

Remove a reaction

module.knowledgecourse.view—
POST /api/v1/knowledge/articles/{id}/reactions

Mark an article helpful or not helpful

module.knowledgecourse.view—
GET /api/v1/knowledge/categories

List knowledge categories

module.knowledgecourse.view—
GET /api/v1/knowledge/search

Search articles

module.knowledgecourse.view—
POST /api/v1/learn/anything

Explain a topic from the organisation's sources

module.aiai.use—
GET /api/v1/learn/courses

List courses

module.learncourse.viewlearnCourses
GET /api/v1/learn/courses/{id}

Get one course

module.learncourse.viewlearnCourse
GET /api/v1/learn/courses/{id}/lessons

List a course's modules and lessons

module.learncourse.view—
GET /api/v1/learn/courses/{id}/outline

A course outline with the learner's progress

module.learncourse.view—
GET /api/v1/learn/enrolments

List enrolments

module.learnlearner.progress.tenantlearnEnrolments
POST /api/v1/learn/enrolments

Enrol a person in a course

module.learncourse.assign—
POST /api/v1/learn/enrolments/self

Enrol the learner in a course (self-service)

module.learncourse.view—
GET /api/v1/learn/lessons/{id}

Get a lesson as the learner sees it

module.learncourse.view—
PUT /api/v1/learn/lessons/{id}/progress

Record lesson progress

module.learncourse.view—
POST /api/v1/learn/lessons/{id}/quiz-answers

Submit a quiz answer

module.learncourse.view—
GET /api/v1/learn/scorm/{packageId}/attempt

Read a learner's SCORM attempt

module.standardscourse.view—
POST /api/v1/learn/scorm/{packageId}/attempt

Start or resume a SCORM attempt

module.standardscourse.view—
POST /api/v1/learn/scorm/{packageId}/track

Record a SCORM call

module.standardscourse.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.learncourse.view—
PATCH /api/v1/me/profile

Update the learner's profile

module.learncourse.view—
GET /api/v1/me/record

The learner's learning record

module.learncourse.view—
GET /api/v1/me/timeline

The learner's timeline

module.learncourse.view—
GET /api/v1/me/transcript

The learner's transcript

module.learncourse.view—
GET /api/v1/org/departments

List departments

module.orgreport.view.tenantorgDepartments
GET /api/v1/org/groups

List groups

module.orgreport.view.tenantorgGroups
POST /api/v1/org/groups/{id}/members

Add a person to a group

module.orgsettings.manage—
GET /api/v1/org/users

List organisation members

module.orgreport.view.tenantorgUsers
POST /api/v1/org/users

Invite a person

module.orguser.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.pathscourse.view—
GET /api/v1/paths/enrolments

List learning-path enrolments

module.pathsreport.view.tenantpathsEnrolments
POST /api/v1/paths/enrolments/self

Enrol the learner in a learning path (self-service)

module.pathscourse.view—
GET /api/v1/paths/paths

List learning paths

module.pathsreport.view.tenantpathsPaths
GET /api/v1/podcasts/episodes

List podcast episodes

module.learncourse.view—
PUT /api/v1/podcasts/episodes/{id}/progress

Save podcast progress

module.learncourse.view—
GET /api/v1/podcasts/feeds

List podcast feeds

module.learncourse.view—
DELETE /api/v1/podcasts/feeds/{id}/subscribe

Unfollow a podcast feed

module.learncourse.view—
POST /api/v1/podcasts/feeds/{id}/subscribe

Follow a podcast feed

module.learncourse.view—
GET /api/v1/programmes

List a learner's programmes

module.pathscourse.view—
GET /api/v1/programmes/{id}

A learner's programme and its steps

module.pathscourse.view—
POST /api/v1/programmes/enrolments

Enrol a person in a programme

module.pathscourse.assign—
GET /api/v1/skills/competencies

List competencies

module.skillsreport.view.tenantskillsCompetencies
GET /api/v1/skills/gaps

The learner's skill gaps

module.skillscourse.view—
GET /api/v1/skills/scores

List learner skill scores

module.skillsreport.view.tenantskillsScores
GET /api/v1/skills/self-assessments

Read the learner's self-assessments

module.skillscourse.view—
PUT /api/v1/skills/self-assessments

Save the learner's self-assessments

module.skillscourse.view—
GET /api/v1/skills/skills

List skills

module.skillsreport.view.tenantskillsCatalogue
GET /api/v1/social/course-discussions

List course discussion threads

module.communitycourse.view—
POST /api/v1/social/course-discussions

Start or reply to a course discussion

module.communitycourse.view—
GET /api/v1/social/feed

The learner's feed

module.communitycourse.view—
POST /api/v1/social/moderation/{postId}

Moderate a post

module.communitycommunity.moderate—
GET /api/v1/social/moderation/queue

Moderation queue

module.communitycommunity.moderate—
GET /api/v1/social/posts

List posts

module.communitycourse.view—
POST /api/v1/social/posts

Publish a post

module.communitycourse.view—
DELETE /api/v1/social/posts/{id}

Delete a post

module.communitycourse.view—
GET /api/v1/social/posts/{id}

Get one post

module.communitycourse.view—
PATCH /api/v1/social/posts/{id}

Edit a post

module.communitycourse.view—
GET /api/v1/social/posts/{id}/comments

List comments on a post

module.communitycourse.view—
POST /api/v1/social/posts/{id}/comments

Comment on a post

module.communitycourse.view—
DELETE /api/v1/social/posts/{id}/comments/{commentId}

Delete a comment

module.communitycourse.view—
PATCH /api/v1/social/posts/{id}/comments/{commentId}

Edit a comment

module.communitycourse.view—
DELETE /api/v1/social/posts/{id}/reactions

Remove a reaction

module.communitycourse.view—
POST /api/v1/social/posts/{id}/reactions

React to a post

module.communitycourse.view—
POST /api/v1/social/posts/{id}/report

Report a post

module.communitycourse.view—
DELETE /api/v1/social/posts/{id}/saves

Unsave a post

module.communitycourse.view—
POST /api/v1/social/posts/{id}/saves

Save a post

module.communitycourse.view—
GET /api/v1/social/spaces

List spaces

module.communitycourse.view—
GET /api/v1/social/spaces/{id}

Get one space

module.communitycourse.view—
DELETE /api/v1/social/spaces/{id}/members

Leave a space, or remove a member

module.communitycourse.view—
POST /api/v1/social/spaces/{id}/members

Join a space, or add a member

module.communitycourse.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_size defaults to 25 and is capped at 100. A larger value is silently reduced to the cap rather than refused.
  • meta.cursor.next is null on the last page. Treat the cursor as opaque; its encoding is not part of the contract.
  • sort=field or sort=-field for 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. __typename still 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 size8,000 bytes
Root fields per query10
Selection depth5
Estimated cost1000
Cost formulasum over root fields of (rows requested × leaf fields selected per row) + 1
Rate limitone 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.

StatusCodeWhat happened / what to doRetry?
400bad_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
413payload_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
401unauthorized

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
401key_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
403scope_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
403module_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
403permission_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
404not_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
404route_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
410version_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
429rate_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
500internal_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
503api_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
503api_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
503feature_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
403partner_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
403partner_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
403partner_suspended

The partner is suspended. Every Partner API route refuses while it is; customer organisations' own keys are unaffected.

Contact the platform operator.

no
404tenant_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
403partner_child_limit

The partner contract's `max_child_tenants` is reached.

Ask the platform operator to raise the contract ceiling.

no
422wrong_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
403module_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
409partner_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
422partner_tier_free

A customer organisation is never on the free tier.

Choose a paid tier, or omit `tier` for the contract default.

no
422trial_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
422unknown_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
409partner_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
400learner_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
404learner_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
403origin_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
403target_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
422email_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
409email_in_use

That email address already belongs to an account outside this organisation.

Use a different address, or omit it.

no
402learner_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
402learner_limit_reached

The organisation's plan seat limit is reached.

Move the organisation to a plan with more seats.

no
402limit_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
409legal_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
409conflict

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
422validation_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
429ai_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
402allowance_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
402insufficient_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
503guardrails_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
501format_unavailable

The requested export format is not available on this deployment (for example parquet for BI export).

Ask for csv or json instead.

no
403feature_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
502ai_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
409completion_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
409idempotency_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
403lesson_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
409proctored_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
413too_large

The upload is larger than the 10 MB limit for assessment answers.

Upload a smaller file.

no
409topups_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
409payment_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
502payment_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
403partner_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
403ai_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
403product_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
422unknown_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
422product_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
403product_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
422products_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
403sandbox_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
403sandbox_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
403sandbox_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
403test_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
403test_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
409test_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.

Full Feature Free Trial

Ready for learning governance without the enterprise tax?

Start with a free trial. No card required.

No credit card required. Instant account setup in under 60 seconds.