Version 0.25.3 · OpenAPI 3.1.0
Base URL: https://gtaa.botreeinc.com
OIDC access token issued by one of this deployment's registered sign-in providers.
Authorization: Bearer <access token>Endpoints marked public are served without an access token.
Beyond a valid access token, most endpoints require one of the permissions below — each endpoint's description names the one it checks. Permissions are granted through department roles: a role lists the permission strings it grants, and every holder of the role holds them. The set is closed; a role granting any other string grants nothing.
View learning records and their supporting evidence.
Create, edit, approve and archive records and their evidence.
View the subject matters records are recorded against.
Create, edit and archive subject matters.
View certification types, role requirements, and other users' status.
Manage certification types, rules and role requirements; set due dates, record sign-offs, decide requests, and grant or revoke certifications.
View question banks with their answers, quiz configuration, assignments, and other users' attempts and results.
Manage question banks and questions; create, configure, assign, publish and archive quizzes; release held-back results.
View the catalogue of external LMS servers, their courses, external-user mappings, and imported course grades.
Manage external LMS servers, their courses and external-user mappings. Importing grades needs records:write instead, since it writes learning records.
View the hosted SCORM course catalogue, and other users' attempts and results.
Upload SCORM packages, edit course metadata, and archive courses. Taking an available course needs no grant; the course's audience — the organization, or its owning department — is the authorization.
Create departments beneath the organizations and departments in scope, and the roles departments define. Creating organizations stays superuser-only.
A permission applies within the department whose role granted it: it covers rows owned by that department, by any department nested beneath it at any depth, and by the department's organization directly — but not by sibling departments. A row the caller cannot read answers 404, not 403.
/api/auth/token-info Bearer tokenExample protected endpoint: echoes back the claims of the bearer token after it has been validated against the OIDC provider.
| Status | Description | Body |
|---|---|---|
| 200 | The validated access token. | TokenInfo |
| 400 | The Authorization header does not use the Bearer scheme. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token is missing a required scope. | ErrorResponse |
| 500 | The access token could not be validated. | ErrorResponse |
/api/organizations Bearer tokenLists every organization in the directory, archived ones included. Requires a valid access token.
| Status | Description | Body |
|---|---|---|
| 200 | Every organization, ordered by name. | array of OrganizationSummary |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations Bearer tokenCreates a new top-level organization. Requires a superuser's access token.
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | Unique across all organizations. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created organization. | Organization |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 409 | An organization with that slug already exists. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId} Bearer tokenChanges an organization's slug, name or description; omitted fields keep their values. Requires a superuser's access token, like creating one.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | Unique across all organizations. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null or empty to clear the description. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The updated organization. | Organization |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | No organization has that id. | ErrorResponse |
| 409 | Another organization already has that slug. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId}/departments Bearer tokenCreates a department in an organization, optionally nested under a parent department in the same organization. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the parent — the organization for a top-level department, the parent department for a nested one.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| parent_department_id | string (uuid) | no | Nests the new department under one in the same organization. |
| Status | Description | Body |
|---|---|---|
| 201 | The created department. | Department |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is neither a superuser nor a directory:write holder whose scope covers the parent. | ErrorResponse |
| 404 | The organization does not exist, or the parent department is not in that organization. | ErrorResponse |
| 409 | The organization already has a department with that slug. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId}/departments/{departmentId} Bearer tokenChanges a department's slug, name or description, or moves it under another parent in the same organization; omitted fields keep their values. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the department — and, when moving it, the new parent too.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| departmentId | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null or empty to clear the description. at most 2000 characters |
| parent_department_id | string (uuid) | null | no | Moves the department under another in the same organization — never itself or anything nested beneath it. Send null to make it top level. |
| Status | Description | Body |
|---|---|---|
| 200 | The updated department. | Department |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is neither a superuser nor a directory:write holder whose scope covers the department (and the new parent, when moving it). | ErrorResponse |
| 404 | The organization does not exist, the department is not in that organization, or the new parent is not in that organization. | ErrorResponse |
| 409 | The organization already has a department with that slug, or the move would nest the department beneath itself. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId}/departments/{departmentId}/roles Bearer tokenCreates a role in a department of an organization, with the permission strings it grants. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the department.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| departmentId | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | Unique within the department. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| permissions | array of string | no | The permissions the role grants, from the set the code understands. Duplicates are collapsed; an empty array grants nothing. at most 13 items · defaults to [] |
| Status | Description | Body |
|---|---|---|
| 201 | The created role. | DepartmentRole |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is neither a superuser nor a directory:write holder whose scope covers the department. | ErrorResponse |
| 404 | The organization does not exist, or the department is not in that organization. | ErrorResponse |
| 409 | The department already has a role with that slug. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId} Bearer tokenChanges a role's slug, name or description, or replaces the permission set it grants; omitted fields keep their values. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the department.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| departmentId | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| roleId | path | string (uuid) | yes | e.g. 4c1d2f6a-8e0b-45c7-9a92-6d84a4f4c9b1 |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | Unique within the department. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null or empty to clear the description. at most 2000 characters |
| permissions | array of string | no | Replaces the whole permission set, from the set the code understands. Duplicates are collapsed; an empty array grants nothing. at most 13 items |
| Status | Description | Body |
|---|---|---|
| 200 | The updated role. | DepartmentRole |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is neither a superuser nor a directory:write holder whose scope covers the department. | ErrorResponse |
| 404 | The organization does not exist, the department is not in that organization, or the department has no role with that id. | ErrorResponse |
| 409 | The department already has a role with that slug. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id}/users/import Bearer tokenAdds every row of an uploaded CSV file to the organization's user directory in one request. The file needs a header row naming a user id, name and email column; rows whose subject is already in the directory are skipped rather than overwritten, so an import can safely be re-run. A file with any invalid row imports nothing and reports the problems, so a corrected file can simply be uploaded again. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| Field | Type | Required | Description |
|---|---|---|---|
| provider_id | string | yes | Sign-in provider UUID id or slug; every row's user_id is the OIDC `sub` claim the member will present at this provider. at least 1 character |
| file | string (binary) | yes | CSV file, at most 1 MB: a header row naming a `user_id` (the OIDC `sub` claim at the named provider; `user_subject`, `subject` and `sub` are accepted too), `name` (or `display_name`) and `email` column in any order, then one member per row. Header matching is case-insensitive and extra columns are ignored. A member's name and email cells may be left blank; they seed a newly created account's profile. |
| Status | Description | Body |
|---|---|---|
| 200 | The import's outcome: what was added and what was already present. | DirectoryImportSummary |
| 400 | The file is not parseable CSV, a required column is missing from the header, a row failed validation, a subject appears twice, or the file has no (or too many) data rows. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | No organization or sign-in provider has that id or slug. | ErrorResponse |
| 409 | A concurrent sign-in or import raced this one; re-run the import. | ErrorResponse |
| 413 | The file is larger than 1 MB. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id}/users Bearer tokenLists every member of the organization's user directory, named members first, each with the number of roles they currently hold. Requires a valid access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| Status | Description | Body |
|---|---|---|
| 200 | The directory's members. | array of DirectoryUser |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No organization has that id or slug. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations/{org_id}/users Bearer tokenAdds a user to the organization's directory: an existing account by user_id, or a pre-provisioned identity by provider_id + subject — the account is created on the spot when that identity is new. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | no | The member's account id (`users.id`). Mutually exclusive with provider_id/subject. |
| provider_id | string | no | Sign-in provider UUID id or slug. at least 1 character |
| subject | string | no | OIDC `sub` claim the member will present at that provider. 1–255 characters |
| display_name | string | no | Starting profile for a newly created account; ignored for an existing one. 1–300 characters |
| string | no | 1–300 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The added member. | DirectoryUser |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | The organization, the named user, or the named provider does not exist. | ErrorResponse |
| 409 | That user is already in the organization's directory. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id}/users/{user_id} Bearer tokenReturns one member of the organization's directory, with every role grant they hold there — expired ones included. Requires a valid access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| user_id | path | string (uuid) | yes | The member's account id (`users.id`). |
| Status | Description | Body |
|---|---|---|
| 200 | The member and their grants. | DirectoryUserWithRoles |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The organization does not exist, or that user is not in its directory. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations/{org_id}/users/{user_id} Bearer tokenRemoves a member from the organization's directory, revoking every role they hold there. The account itself (and its history) remains. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| user_id | path | string (uuid) | yes | The member's account id (`users.id`). |
| Status | Description | Body |
|---|---|---|
| 200 | The removed member, with the grants that were revoked. | DirectoryUserWithRoles |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | The organization does not exist, or that user is not in its directory. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id}/users/{user_id}/roles Bearer tokenLists every role grant a member holds in the organization, expired ones included — check expires_at. Requires a valid access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| user_id | path | string (uuid) | yes | The member's account id (`users.id`). |
| Status | Description | Body |
|---|---|---|
| 200 | The member's grants, by department and role name. | array of DirectoryUserRole |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The organization does not exist, or that user is not in its directory. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations/{org_id}/users/{user_id}/roles Bearer tokenGrants a member one of the roles defined by a department of the organization, open-ended or until an expiry. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| user_id | path | string (uuid) | yes | The member's account id (`users.id`). |
| Field | Type | Required | Description |
|---|---|---|---|
| department_id | string | yes | Department UUID id or slug, resolved within the organization. at least 1 character |
| role_id | string | yes | Role UUID id or slug, resolved within the department. at least 1 character |
| expires_at | string (date-time) | no | When the grant lapses; omit for an open-ended one. Must be in the future. |
| Status | Description | Body |
|---|---|---|
| 201 | The created grant. | DirectoryUserRole |
| 400 | The request body failed validation, or expires_at is not in the future. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | The organization, the member in its directory, the department, or the role does not exist. | ErrorResponse |
| 409 | The member already holds that role. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id}/users/{user_id}/roles/{role_id} Bearer tokenChanges when a member's grant expires — renewing a lapsed one, scheduling an end, or making it open-ended with null. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| user_id | path | string (uuid) | yes | The member's account id (`users.id`). |
| role_id | path | string | yes | Role UUID id or slug, resolved among the member's grants. A member holding identically-slugged roles in two departments must be addressed by the UUID. |
| Field | Type | Required | Description |
|---|---|---|---|
| expires_at | string (date-time) | null | yes | New expiry of the grant; null makes it open-ended. |
| Status | Description | Body |
|---|---|---|
| 200 | The updated grant. | DirectoryUserRole |
| 400 | The request body failed validation, expires_at does not follow the grant time, or the role slug is ambiguous across the member's departments. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | The organization, the member in its directory, or the member's grant of that role does not exist. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id}/users/{user_id}/roles/{role_id} Bearer tokenRemoves one of a member's role grants. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| user_id | path | string (uuid) | yes | The member's account id (`users.id`). |
| role_id | path | string | yes | Role UUID id or slug, resolved among the member's grants. A member holding identically-slugged roles in two departments must be addressed by the UUID. |
| Status | Description | Body |
|---|---|---|
| 200 | The revoked grant. | DirectoryUserRole |
| 400 | The role slug is ambiguous across the member's departments. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | The organization, the member in its directory, or the member's grant of that role does not exist. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/managers Bearer tokenLists the roles managing this role and the roles it manages. Holders of a manager role (or of any role above it in the chain) may approve or reject the pending learning-record submissions of the subordinate role's holders.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| departmentId | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| roleId | path | string (uuid) | yes | e.g. 9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10 |
| Status | Description | Body |
|---|---|---|
| 200 | The role's reporting lines, both directions. | RoleManagers |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The organization, department, or role does not exist there. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/managers Bearer tokenAdds a reporting line: the named role becomes a manager of the role in the path. Both roles must belong to the same organization; a line that would close a cycle is refused. Requires a superuser's access token, or one whose user holds directory:write in a scope covering both roles' departments.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| departmentId | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| roleId | path | string (uuid) | yes | e.g. 9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10 |
| Field | Type | Required | Description |
|---|---|---|---|
| manager_role_id | string (uuid) | yes | The role to make a manager of this one. Any role of the same organization except this role itself, or one that would close a cycle. |
| Status | Description | Body |
|---|---|---|
| 201 | The created reporting line, manager side resolved. | RoleManagerEdge |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller is neither a superuser nor a directory:write holder whose scope covers both roles' departments. | ErrorResponse |
| 404 | The organization, department, or role does not exist there, or the manager role is not in that organization. | ErrorResponse |
| 409 | The role already reports to that manager, or the line would close a cycle. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/managers/{managerRoleId} Bearer tokenRemoves the reporting line between this role and the named manager role. Requires a superuser's access token, or one whose user holds directory:write in a scope covering both roles' departments.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| departmentId | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| roleId | path | string (uuid) | yes | e.g. 9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10 |
| managerRoleId | path | string (uuid) | yes | e.g. 2e6d0c4a-8b1f-4e7a-9c3d-5a4b6c7d8e9f |
| Status | Description | Body |
|---|---|---|
| 204 | The reporting line is gone. | — |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller is neither a superuser nor a directory:write holder whose scope covers both roles' departments. | ErrorResponse |
| 404 | No such reporting line — a path segment or the line itself does not exist. | ErrorResponse |
| 500 | The directory database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id} Bearer tokenReturns one organization and every department inside it. Requires a valid access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| Status | Description | Body |
|---|---|---|
| 200 | The organization and its departments. | OrganizationWithDepartments |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No organization has that id or slug. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations/{org_id}/departments/{dept_id} Bearer tokenReturns one department of an organization. Requires a valid access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| dept_id | path | string | yes | Department UUID id or slug, resolved within the organization. |
| Status | Description | Body |
|---|---|---|
| 200 | The department. | DepartmentSummary |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The organization, or the department within it, does not exist. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations/{org_id}/departments/{dept_id}/roles Bearer tokenLists the roles a department defines, with the permissions each grants and how many users currently hold it. Requires a valid access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| dept_id | path | string | yes | Department UUID id or slug, resolved within the organization. |
| Status | Description | Body |
|---|---|---|
| 200 | The department's roles, ordered by name. | array of DepartmentRoleSummary |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The organization, or the department within it, does not exist. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/organizations/{org_id}/departments/{dept_id}/roles/{role_id} Bearer tokenReturns one role of a department, with the permissions it grants and how many users currently hold it. Requires a valid access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| dept_id | path | string | yes | Department UUID id or slug, resolved within the organization. |
| role_id | path | string | yes | Role UUID id or slug, resolved within the department. |
| Status | Description | Body |
|---|---|---|
| 200 | The role. | DepartmentRoleSummary |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The organization, department or role does not exist. | ErrorResponse |
| 500 | The directory database could not be read. | ErrorResponse |
/api/providers Bearer tokenEvery registered OpenID Connect provider, disabled ones included. Rows name the env vars their secrets live in; the secrets themselves are never served.
| Status | Description | Body |
|---|---|---|
| 200 | The registered providers, oldest first. | SignInProviderList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The provider catalogue could not be read. | ErrorResponse |
/api/providers Bearer tokenRegisters an OpenID Connect provider whose sign-ins and access tokens this deployment accepts. Secrets stay in the environment: the row names the variables to read. Requires a superuser's access token.
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | at most 100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| display_name | string | yes | 1–200 characters |
| issuer_url | string (uri) | yes | at most 500 characters |
| client_id | string | yes | 1–500 characters |
| client_auth_method | string | yes | one of "client_secret_basic" | "client_secret_post" | "client_secret_jwt" | "private_key_jwt" | "none" |
| client_secret_env | string | no | at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_private_key_env | string | no | at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_assertion_algorithm | string | no | 1–20 characters |
| client_key_id | string | no | 1–200 characters |
| audience | string | no | 1–500 characters |
| login_scope | string | no | Defaults to "openid profile email". 1–500 characters |
| access_token_algorithms | array of string | no | Omit to accept the default algorithm set. at most 20 items |
| Status | Description | Body |
|---|---|---|
| 201 | The registered provider. | SignInProvider |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 409 | The slug or issuer is taken. | ErrorResponse |
| 500 | The provider catalogue could not be written to. | ErrorResponse |
/api/providers/{provider_id} Bearer token| Name | In | Type | Required | Description |
|---|---|---|---|---|
| provider_id | path | string | yes | Provider UUID id or slug. |
| Status | Description | Body |
|---|---|---|
| 200 | The provider. | SignInProvider |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No provider has that id or slug. | ErrorResponse |
| 500 | The provider catalogue could not be read. | ErrorResponse |
/api/providers/{provider_id} Bearer tokenChanges a provider's settings; omitted fields keep their values. Disabling a provider stops its sign-ins and token verification while keeping its identities. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| provider_id | path | string | yes | Provider UUID id or slug. |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | at most 100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| display_name | string | no | 1–200 characters |
| issuer_url | string (uri) | no | at most 500 characters |
| client_id | string | no | 1–500 characters |
| enabled | boolean | no | — |
| client_auth_method | string | no | one of "client_secret_basic" | "client_secret_post" | "client_secret_jwt" | "private_key_jwt" | "none" |
| client_secret_env | string | null | no | Omit to keep the current value; null clears it. at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_private_key_env | string | null | no | at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_assertion_algorithm | string | null | no | 1–20 characters |
| client_key_id | string | null | no | 1–200 characters |
| audience | string | null | no | 1–500 characters |
| login_scope | string | no | 1–500 characters |
| access_token_algorithms | array of string | no | at most 20 items |
| Status | Description | Body |
|---|---|---|
| 200 | The updated provider. | SignInProvider |
| 400 | The request body failed validation, or the settings violate a constraint. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | No provider has that id or slug. | ErrorResponse |
| 409 | The slug or issuer is taken. | ErrorResponse |
| 500 | The provider catalogue could not be written to. | ErrorResponse |
/api/providers/{provider_id} Bearer tokenRemoves a provider nothing references. A provider whose identities still exist cannot be deleted — disable it, or detach the identities first. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| provider_id | path | string | yes | Provider UUID id or slug. |
| Status | Description | Body |
|---|---|---|
| 200 | The deleted provider. | SignInProvider |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | No provider has that id or slug. | ErrorResponse |
| 409 | Sign-in identities still reference the provider. | ErrorResponse |
| 500 | The provider catalogue could not be written to. | ErrorResponse |
/api/users/me Bearer tokenThe account the presented token resolves to: canonical profile and sign-in methods. Accounts come into existence on first contact, so this always answers for a valid token.
| Status | Description | Body |
|---|---|---|
| 200 | The caller's account. | UserProfile |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The account could not be read. | ErrorResponse |
/api/users/me/identities/{identity_id} Bearer tokenDetaches a sign-in method from the caller's own account. The last method cannot be unlinked — that would lock the account out; ask a superuser to detach it deliberately.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| identity_id | path | string (uuid) | yes | The identity's id, from the account's identity listing. |
| Status | Description | Body |
|---|---|---|
| 200 | The unlinked sign-in method. | UserIdentity |
| 400 | The identity is the account's last sign-in method. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The identity does not exist, or belongs to another account. | ErrorResponse |
| 500 | The identity could not be detached. | ErrorResponse |
/api/users/{user_id} Bearer tokenSets the account's display name (null clears it); omitted fields keep their values. The name is the canonical profile the app shows everywhere, and a later sign-in whose ID token carries a name claim refreshes it again. A merged (tombstoned) account cannot be edited — its survivor holds the profile. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| user_id | path | string (uuid) | yes | The account's id (`users.id`). |
| Field | Type | Required | Description |
|---|---|---|---|
| display_name | string | null | no | The name shown for the account everywhere; null clears it. 1–200 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The updated account. | UserAccount |
| 400 | The request body failed validation, or the account was merged away. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | No user has that id. | ErrorResponse |
| 500 | The account could not be updated. | ErrorResponse |
/api/users/{user_id}/identities Bearer tokenEvery identity attached to the account. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| user_id | path | string (uuid) | yes | The account's id (`users.id`). |
| Status | Description | Body |
|---|---|---|
| 200 | The account's sign-in methods, oldest first. | array of UserIdentity |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | No user has that id. | ErrorResponse |
| 500 | The identities could not be read. | ErrorResponse |
/api/users/{user_id}/identities Bearer tokenLinks a (provider, subject) pair to the account — the administrative side of identity linking, for provider migrations and rescues. An identity already attached to a *different* account is refused: merge or detach it first. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| user_id | path | string (uuid) | yes | The account's id (`users.id`). |
| Field | Type | Required | Description |
|---|---|---|---|
| provider_id | string | yes | Sign-in provider UUID id or slug. at least 1 character |
| subject | string | yes | The `sub` claim the account's owner presents at that provider. 1–255 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The attached sign-in method. | UserIdentity |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | No user or provider has that id. | ErrorResponse |
| 409 | That identity already belongs to a different account. | ErrorResponse |
| 500 | The identity could not be attached. | ErrorResponse |
/api/users/{user_id}/identities/{identity_id} Bearer tokenRemoves one identity from the account. Detaching the *last* one locks the account out of signing in (its history remains) and is refused unless `force=true` — the deliberate offboarding or compromised-credential case. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| user_id | path | string (uuid) | yes | The account's id (`users.id`). |
| identity_id | path | string (uuid) | yes | The identity's id, from the account's identity listing. |
| force | query | string | no | Set true to detach the account's last sign-in method. |
| Status | Description | Body |
|---|---|---|
| 200 | The detached sign-in method. | UserIdentity |
| 400 | The identity is the account's last sign-in method and force is not set. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | The user or the identity does not exist. | ErrorResponse |
| 500 | The identity could not be detached. | ErrorResponse |
/api/users/{user_id}/merge Bearer tokenAbsorbs the account at {user_id} into into_user_id: identities move over, every reference is repointed at the survivor (redundant memberships, grants and duplicates resolve in the survivor's favour), and the absorbed account is tombstoned. The fix for the duplicate a first sign-in creates before its identity is linked. One transaction, irreversible. Requires a superuser's access token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| user_id | path | string (uuid) | yes | The account's id (`users.id`). |
| Field | Type | Required | Description |
|---|---|---|---|
| into_user_id | string (uuid) | yes | The surviving account everything is repointed at. |
| Status | Description | Body |
|---|---|---|
| 200 | The merge's outcome. | MergeUsersResponse |
| 400 | The merge is refused: a self-merge, or a tombstoned participant. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 404 | One of the accounts does not exist. | ErrorResponse |
| 500 | The merge failed; nothing was changed. | ErrorResponse |
/api/subjects Bearer tokenLists the subject matters the caller can read, newest first, with cursor pagination. Requires subjects:read in scope; rows outside the caller's scope are simply absent.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| department | query | string | no | Department UUID id or slug, resolved within org (which is then required). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of subject matters, and the cursor for the next. | SubjectMatterList |
| 400 | A filter did not resolve, or the cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/subjects Bearer tokenCreates a subject matter owned by an organization, or by one department within it. Requires subjects:write over the owner.
| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the subject belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a subject owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like forklift-safety. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created subject matter. | SubjectMatter |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold subjects:write over the owner. | ErrorResponse |
| 404 | The organization, or the department within it, does not exist. | ErrorResponse |
| 409 | The organization already has a subject with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/subjects/{subject_id} Bearer tokenReturns one subject matter, by UUID id or by slug resolved within ?org=. Requires subjects:read over its owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| subject_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The subject matter. | SubjectMatter |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible subject matter has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/subjects/{subject_id} Bearer tokenChanges a subject matter's slug, name or description. Requires subjects:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| subject_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The updated subject matter. | SubjectMatter |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this subject but does not hold subjects:write over it. | ErrorResponse |
| 404 | No visible subject matter has that id or slug. | ErrorResponse |
| 409 | The organization already has a subject with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/subjects/{subject_id} Bearer tokenSoft-deletes a subject matter by setting archived_at; records filed under it keep their tags. Requires subjects:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| subject_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The archived subject matter. | SubjectMatter |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this subject but does not hold subjects:write over it. | ErrorResponse |
| 404 | No visible subject matter has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/records/submissions Bearer tokenCreates a learning record together with its evidence rows, atomically. The record and evidence parts are JSON-encoded (shaped like CreateLearningRecordRequest and an array of CreateSupportingEvidenceRequest); the bytes of at most one file evidence entry travel in the file part, so no separate upload round trip is needed. Requires records:write over the owner — or none at all for a pending submission about yourself, which a manager then approves.
| Field | Type | Required | Description |
|---|---|---|---|
| record | string | yes | JSON-encoded record, in the shape of the CreateLearningRecordRequest schema. Callers without records:write over the owner must use status "pending" and omit learner_user_id (the record is about themselves). at least 1 character |
| evidence | string | yes | JSON-encoded array of evidence, each entry in the shape of the CreateSupportingEvidenceRequest schema (without pathname — a file travels in the file part instead). At most one entry may be of evidence_type file. at least 1 character |
| file | string (binary) | no | The bytes of the one file evidence entry, sent as a file part. Required exactly when the evidence array contains a file entry; the part's size and content type must match what that entry's payload declares. |
| Status | Description | Body |
|---|---|---|
| 201 | The created record and its evidence rows. | LearningRecordSubmission |
| 400 | A part failed validation, or the file part does not match the declared file evidence. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold records:write over the owner, and this is not a pending submission about themselves. | ErrorResponse |
| 404 | The organization, department, learner, or a tagged subject does not exist there. | ErrorResponse |
| 409 | The organization already has a record with that external_id. | ErrorResponse |
| 500 | The file could not be stored, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/records Bearer tokenLists the learning records the caller can read — their records:read scope, their own records, and the pending submissions of learners in their manager chain — newest first, with cursor pagination.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| department | query | string | no | Department UUID id or slug, resolved within org (which is then required). |
| learner | query | string (uuid) | no | Only records about this learner (`users.id`). |
| subject | query | string | no | Only records filed under this subject matter: UUID id, or slug resolved within org. |
| status | query | string | no | — |
| evidence_type | query | string | no | Only records carrying at least one evidence row of this type. |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of records, and the cursor for the next. | LearningRecordList |
| 400 | A filter did not resolve, or the cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/records Bearer tokenCreates a record owned by an organization or one of its departments, optionally tagged with subjects. Requires records:write over the owner. Omit learner_user_id to record about yourself.
| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the record belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a record owned by the organization directly. |
| learner_user_id | string (uuid) | no | `users.id` of the learner. Omit to record about yourself — the snapshots below then default to your own profile. |
| learner_display_name | string | no | 1–300 characters |
| learner_email | string | no | 1–300 characters |
| title | string | yes | 1–300 characters |
| description | string | no | at most 5000 characters |
| status | string | no | Callers without records:write over the owner may only create pending records about themselves. rejected cannot be set directly — it is entered through the reject endpoint. one of "pending" | "in_progress" | "completed" | "expired" | "revoked" · defaults to "completed" |
| completed_at | string (date-time) | null | no | — |
| expires_at | string (date-time) | null | no | Must be after completed_at when both are set. |
| external_id | string | no | Importer-assigned id; unique per owning organization, so re-runs are idempotent. 1–300 characters |
| subject_matter_ids | array of string (uuid) | no | Subjects to file the record under; must belong to the same organization. at most 100 items · defaults to [] |
| Status | Description | Body |
|---|---|---|
| 201 | The created record. | LearningRecord |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold records:write over the owner. | ErrorResponse |
| 404 | The organization, department, learner, or a tagged subject does not exist there. | ErrorResponse |
| 409 | The organization already has a record with that external_id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/records/{record_id} Bearer tokenReturns one record with its subjects. Requires records:read over its owner, being its learner, or — while it is pending — being in its learner's manager chain.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Status | Description | Body |
|---|---|---|
| 200 | The record. | LearningRecord |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/records/{record_id} Bearer tokenChanges a record's fields, and replaces its subject set when subject_matter_ids is present. Requires records:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Field | Type | Required | Description |
|---|---|---|---|
| owner_department_id | string (uuid) | null | no | Move the record to another department of the same organization; null hands it to the organization directly. |
| learner_display_name | string | null | no | 1–300 characters |
| learner_email | string | null | no | 1–300 characters |
| title | string | no | 1–300 characters |
| description | string | null | no | at most 5000 characters |
| status | string | no | rejected cannot be set directly — use the reject endpoint. The learner of a rejected record may set it back to pending, which resubmits it and clears the rejection. one of "pending" | "in_progress" | "completed" | "expired" | "revoked" |
| completed_at | string (date-time) | null | no | — |
| expires_at | string (date-time) | null | no | — |
| external_id | string | null | no | 1–300 characters |
| subject_matter_ids | array of string (uuid) | no | When present, replaces the record's whole subject set. at most 100 items |
| Status | Description | Body |
|---|---|---|
| 200 | The updated record. | LearningRecord |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this record but does not hold records:write over it. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 409 | The organization already has a record with that external_id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/records/{record_id} Bearer tokenSoft-deletes a record by setting archived_at; its evidence and blobs are kept, so it can be un-archived. Requires records:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Status | Description | Body |
|---|---|---|
| 200 | The archived record. | LearningRecord |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this record but does not hold records:write over it. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/records/{record_id}/approve Bearer tokenMoves a learner's pending submission into the real history — completed by default, or in_progress — and stamps who approved it and when. Requires records:write over the record's owner, or a role in the learner's manager chain; a learner cannot approve their own submission.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Field | Type | Required | Description |
|---|---|---|---|
| status | string | no | What the record becomes on approval: completed (default), or in_progress for learning that is still under way. one of "completed" | "in_progress" · defaults to "completed" |
| Status | Description | Body |
|---|---|---|
| 200 | The approved record. | LearningRecord |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller neither holds records:write over the record's owner nor is in the learner's manager chain. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 409 | The record is not pending — already approved, or archived. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/records/{record_id}/reject Bearer tokenSends a learner's pending submission back with a note saying what to fix, and stamps who rejected it and when. The learner may edit the record and resubmit it to pending, which clears the rejection. Requires records:write over the record's owner, or a role in the learner's manager chain; a learner cannot reject their own submission.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Field | Type | Required | Description |
|---|---|---|---|
| note | string | yes | Why the submission is rejected — what the learner should fix before resubmitting. Shown to the learner on the record. 1–2000 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The rejected record, carrying the note for its learner. | LearningRecord |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller neither holds records:write over the record's owner nor is in the learner's manager chain. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 409 | The record is not pending — already decided, or archived. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/records/{record_id}/evidence Bearer tokenLists every evidence row of a record, newest first. Requires records:read over the record, being its learner, or — while it is pending — being in its learner's manager chain.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Status | Description | Body |
|---|---|---|
| 200 | The record's evidence, newest first. | array of SupportingEvidence |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/records/{record_id}/evidence Bearer tokenAttaches one measurement to a record. For file evidence, upload the file first via the uploads endpoint and pass the resulting pathname here. Requires records:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | no | Human label for the measurement, e.g. "Final theory exam". 1–300 characters |
| payload | EvidencePayload | yes | — |
| pathname | string | no | For file evidence only: the pathname issued by the uploads endpoint, after the file has been PUT there. at least 1 character |
| Status | Description | Body |
|---|---|---|
| 201 | The created evidence row. | SupportingEvidence |
| 400 | The payload failed validation, or the uploaded blob is missing or does not match it. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this record but does not hold records:write over it. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 409 | That upload has already been attached to an evidence row. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/records/{record_id}/evidence/uploads Bearer tokenIssues a presigned URL to PUT one file straight to private blob storage, scoped to this record, the declared content type and a 25 MB ceiling. Requires records:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| record_id | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| content_type | string | yes | one of "application/pdf" | "image/png" | "image/jpeg" | "image/webp" |
| size_bytes | integer | yes | maximum 26214400 |
| Status | Description | Body |
|---|---|---|
| 201 | Where to PUT the file, and the evidence id minted for it. | UploadTicket |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this record but does not hold records:write over it. | ErrorResponse |
| 404 | No visible record has that id. | ErrorResponse |
| 500 | The upload URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/evidence/{evidence_id} Bearer tokenChanges an evidence row's label or replaces its payload (same evidence_type; not for files). Requires records:write over the owning record.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| evidence_id | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null to clear the label. 1–300 characters |
| payload | any | no | — |
| Status | Description | Body |
|---|---|---|
| 200 | The updated evidence row. | SupportingEvidence |
| 400 | The payload failed validation, changed type, or replaced a file payload. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read the record but does not hold records:write over it. | ErrorResponse |
| 404 | No visible evidence row has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/evidence/{evidence_id} Bearer tokenRemoves an evidence row for good — a mistaken measurement is deleted, not archived — and deletes its blob, if it has one. Requires records:write over the owning record.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| evidence_id | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| Status | Description | Body |
|---|---|---|
| 204 | The evidence row (and its blob, if any) is gone. | — |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read the record but does not hold records:write over it. | ErrorResponse |
| 404 | No visible evidence row has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/evidence/{evidence_id}/download Bearer tokenIssues a short-lived presigned GET for the evidence row's private blob, after re-checking read permission. Requires records:read over the owning record, being its learner, or — while the record is pending — being in its learner's manager chain.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| evidence_id | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| Status | Description | Body |
|---|---|---|
| 200 | Where to fetch the file from, for the next five minutes. | DownloadTicket |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible evidence row has that id, or it has no file attached. | ErrorResponse |
| 500 | The download URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/certifications/status Bearer tokenEvaluates every certification requirement of a user: the types their unexpired role grants require, the current award, the due date, and per-rule progress since the current award. Any bearer user may read their own status; reading another user's requires certifications:read, and returns only types in the caller's scope.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| user | query | string (uuid) | no | Evaluate this user (`users.id`) instead of the caller. Requires certifications:read; the result is filtered to types in the caller's scope. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The user's evaluated certification requirements. | UserCertificationStatusList |
| 400 | The org filter did not resolve. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | Another user was asked for without certifications:read. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/certifications/{cert_id}/claim Bearer tokenAwards the caller a certification of an automatic-mode type they are required to hold, after the server re-validates that every proof rule is satisfied. Answers 409 when a rule is not met, or when the current award changed under the claim (retry after re-reading status).
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string (uuid) | yes | UUID id of the certification type. |
| Status | Description | Body |
|---|---|---|
| 201 | The new award. | CertificationAward |
| 400 | The type is awarded by approval — file a request instead. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | No unexpired role grant of the caller requires this type. | ErrorResponse |
| 404 | That certification type does not exist, or is archived. | ErrorResponse |
| 409 | A proof rule is not satisfied, or the current award changed underneath. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id}/request Bearer tokenFiles a request for an approval-mode type the caller is required to hold, to be decided by a holder of certifications:write. One pending request per user and type.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string (uuid) | yes | UUID id of the certification type. |
| Field | Type | Required | Description |
|---|---|---|---|
| message | string | no | Optional note to whoever decides the request. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The filed request. | CertificationRequest |
| 400 | The type is automatic — claim it instead. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | No unexpired role grant of the caller requires this type. | ErrorResponse |
| 404 | That certification type does not exist, or is archived. | ErrorResponse |
| 409 | A request is already pending, or the caller is not in the organization's directory. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-requests/{request_id}/withdraw Bearer tokenWithdraws the caller's own still-pending request.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| request_id | path | string (uuid) | yes | UUID id of the certification request. |
| Status | Description | Body |
|---|---|---|
| 200 | The withdrawn request. | CertificationRequest |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No request of the caller has that id. | ErrorResponse |
| 409 | The request has already been decided. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-requests/{request_id}/approve Bearer tokenApproves a pending request: inserts the award and stamps the request in one transaction. The deciding human is the authority — rules are not re-checked. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| request_id | path | string (uuid) | yes | UUID id of the certification request. |
| Field | Type | Required | Description |
|---|---|---|---|
| decision_note | string | no | Optional note recorded with the decision, shown to the requester. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The approved request, with the resulting award's id. | CertificationRequest |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this request but does not hold certifications:write. | ErrorResponse |
| 404 | No visible request has that id. | ErrorResponse |
| 409 | The request has already been decided or withdrawn. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-requests/{request_id}/reject Bearer tokenRejects a pending request. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| request_id | path | string (uuid) | yes | UUID id of the certification request. |
| Field | Type | Required | Description |
|---|---|---|---|
| decision_note | string | no | Optional note recorded with the decision, shown to the requester. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The rejected request. | CertificationRequest |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this request but does not hold certifications:write. | ErrorResponse |
| 404 | No visible request has that id. | ErrorResponse |
| 409 | The request has already been decided or withdrawn. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-requests Bearer tokenThe approvals queue: requests whose certification type the caller holds certifications:write over, newest first, with cursor pagination. Defaults to pending requests only.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| status | query | string | no | Filter requests by status. Defaults to pending — the open queue. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of requests, and the cursor for the next. | CertificationRequestList |
| 400 | The org filter did not resolve, or the cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/certifications/{cert_id}/grants Bearer tokenAwards a certification to a user without a request — for paper history, external audits, or admin discretion. `awarded_at` may be backdated; the recert clock counts from it. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string (uuid) | yes | UUID id of the certification type. |
| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | yes | `users.id` of the user the certification is granted to. |
| awarded_at | string (date-time) | no | Omit for now; set to backdate a grant, e.g. one imported from paper. |
| Status | Description | Body |
|---|---|---|
| 201 | The new award. | CertificationAward |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this type but does not hold certifications:write. | ErrorResponse |
| 404 | No visible certification type has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id}/awards Bearer tokenLists the award events of one certification type, newest first, revoked ones included. Requires certifications:read over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string (uuid) | yes | UUID id of the certification type. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| Status | Description | Body |
|---|---|---|
| 200 | The most recent awards of the type. | CertificationAwardList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible certification type has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/certification-awards/{award_id}/revoke Bearer tokenRevokes one award. The row stays as history; the user's status reverts to outstanding (or to their previous unrevoked award). Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| award_id | path | string (uuid) | yes | UUID id of the award. |
| Status | Description | Body |
|---|---|---|
| 200 | The revoked award. | CertificationAward |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this award but does not hold certifications:write. | ErrorResponse |
| 404 | No visible award has that id. | ErrorResponse |
| 409 | The award is already revoked. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id}/due-dates Bearer tokenLists the admin-set per-person initial due dates of one certification type, soonest first. Requires certifications:read over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string (uuid) | yes | UUID id of the certification type. |
| Status | Description | Body |
|---|---|---|
| 200 | The type's due dates. | CertificationDueDateList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible certification type has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/certifications/{cert_id}/due-dates/{user_id} Bearer tokenSets (or replaces) when one organization member's initial certification falls due, overriding any role-level initial due date. Requires certifications:write over the type's owner. The member must be in the organization's directory.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string (uuid) | yes | UUID id of the certification type. |
| user_id | path | string (uuid) | yes | `users.id` of the organization member. |
| Field | Type | Required | Description |
|---|---|---|---|
| initial_due_at | string (date-time) | yes | When this user's initial certification falls due. Takes precedence over any role-level initial due date, earlier or later alike. |
| Status | Description | Body |
|---|---|---|
| 200 | The stored due date. | CertificationDueDate |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this type but does not hold certifications:write. | ErrorResponse |
| 404 | No visible certification type has that id, or the user is not in the organization's directory. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id}/due-dates/{user_id} Bearer tokenClears a member's per-person initial due date; their requirement falls back to the role-level initial due date, or shows "No due date set" when no role sets one. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string (uuid) | yes | UUID id of the certification type. |
| user_id | path | string (uuid) | yes | `users.id` of the organization member. |
| Status | Description | Body |
|---|---|---|
| 200 | The removed due date. | CertificationDueDate |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this type but does not hold certifications:write. | ErrorResponse |
| 404 | No visible certification type has that id, or no due date was set. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-rules/{rule_id}/sign-offs Bearer tokenLists the sign-offs recorded against one rule, newest first, optionally for one user. Requires certifications:read over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| rule_id | path | string (uuid) | yes | UUID id of the proof rule. |
| user | query | string (uuid) | no | Evaluate this user (`users.id`) instead of the caller. Requires certifications:read; the result is filtered to types in the caller's scope. |
| Status | Description | Body |
|---|---|---|
| 200 | The rule's sign-offs. | CertificationSignOffList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible rule has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/certification-rules/{rule_id}/sign-offs Bearer tokenRecords that a user satisfies one manual_sign_off rule. `signed_off_at` may be backdated; a renewal needs a sign-off after the current award. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| rule_id | path | string (uuid) | yes | UUID id of the proof rule. |
| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | yes | `users.id` of the user being signed off. |
| signed_off_at | string (date-time) | no | Omit for now; set to backdate a sign-off. |
| note | string | no | at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The recorded sign-off. | CertificationSignOff |
| 400 | The rule is not a manual_sign_off rule, or the body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this rule but does not hold certifications:write. | ErrorResponse |
| 404 | No visible rule has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-sign-offs/{sign_off_id} Bearer tokenHard-deletes a mistaken sign-off, the way evidence is deleted. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| sign_off_id | path | string (uuid) | yes | UUID id of the sign-off. |
| Status | Description | Body |
|---|---|---|
| 200 | The deleted sign-off. | CertificationSignOff |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this sign-off but does not hold certifications:write. | ErrorResponse |
| 404 | No visible sign-off has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications Bearer tokenLists the certification types the caller can read, newest first, with cursor pagination. Requires certifications:read in scope; rows outside the caller's scope are simply absent.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| department | query | string | no | Department UUID id or slug, resolved within org (which is then required). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of certification types, and the cursor for the next. | CertificationTypeList |
| 400 | A filter did not resolve, or the cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/certifications Bearer tokenCreates a certification type owned by an organization, or by one department within it, tied to a subject matter of the same organization. Requires certifications:write over the owner.
| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the certification type belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a type owned by the organization directly. |
| subject_matter_id | string (uuid) | yes | A subject matter of the same organization; immutable once created. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like forklift-operator. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| description_markdown | string | no | Omit or send empty for no long-form markdown description. at most 50000 characters |
| awarding_mode | string | yes | automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides. one of "automatic" | "approval" |
| Status | Description | Body |
|---|---|---|
| 201 | The created certification type. | CertificationType |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold certifications:write over the owner. | ErrorResponse |
| 404 | The organization, the department within it, or the subject matter does not exist (or the subject belongs to another organization). | ErrorResponse |
| 409 | The organization already has a certification type with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id}/attachment-uploads Bearer tokenIssues a presigned URL to PUT one file of any type straight to private blob storage, scoped to this certification type, the declared content type and a 500 MB ceiling. Attach it with POST /certifications/{cert_id}/attachments and the returned pathname afterwards. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| content_type | string | yes | The file's content type; any type is allowed, but the upload pins this one. 1–200 characters |
| size_bytes | integer | yes | maximum 524288000 |
| Status | Description | Body |
|---|---|---|
| 201 | Where to PUT the file, and the pathname the attachment will point at. | DescriptionUploadTicket |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this certification type but lacks certifications:write. | ErrorResponse |
| 404 | No visible certification type matches the path. | ErrorResponse |
| 500 | The upload URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/certifications/{cert_id}/attachments Bearer tokenCreates the attachment at the end of the authored order, pointing at a pathname an upload ticket was issued for (after PUTting the file there). The content type is read back from the store, never from the request. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | The pathname an upload ticket was issued for, after PUTting the file. at least 1 character |
| file_name | string | yes | 1–300 characters |
| label | string | no | Omit or send empty to show the file name instead. at most 200 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created attachment. | DescriptionAttachment |
| 400 | The body failed validation, or the pathname was not uploaded. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this certification type but lacks certifications:write. | ErrorResponse |
| 404 | No visible certification type matches the path. | ErrorResponse |
| 409 | That upload is already attached. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/certifications/{cert_id}/attachments/{attachment_id}/download Bearer tokenIssues a short-lived presigned GET for the private attachment. Readable by anyone an unexpired role grant requires the type of, and by holders of certifications:read over its owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | Where to fetch the file from, for the next five minutes. | DownloadTicket |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible certification type matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The download URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/certifications/{cert_id}/attachments/{attachment_id} Bearer tokenChanges the attachment's label (null or empty clears it back to the file name) or its slot in the authored order. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null or empty to clear the label back to the file name. at most 200 characters |
| position | integer | no | New slot in the authored order. minimum 0 |
| Status | Description | Body |
|---|---|---|
| 200 | The updated attachment. | DescriptionAttachment |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this certification type but lacks certifications:write. | ErrorResponse |
| 404 | No visible certification type matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id}/attachments/{attachment_id} Bearer tokenRemoves the attachment; the blob it pointed at is deleted best-effort. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 204 | The attachment is gone. | — |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this certification type but lacks certifications:write. | ErrorResponse |
| 404 | No visible certification type matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id} Bearer tokenReturns one certification type with its proof rules, by UUID id or by slug resolved within ?org=. Requires certifications:read over its owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The certification type and its rules. | CertificationTypeDetail |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible certification type has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/certifications/{cert_id} Bearer tokenChanges a certification type's slug, name, description or awarding mode. The subject matter is immutable. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| description_markdown | string | null | no | Send null to clear the long-form markdown description. at most 50000 characters |
| awarding_mode | string | no | Changing the mode does not touch existing requests: pending requests on a now-automatic type remain decidable and withdrawable. one of "automatic" | "approval" |
| Status | Description | Body |
|---|---|---|
| 200 | The updated certification type. | CertificationType |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this type but does not hold certifications:write. | ErrorResponse |
| 404 | No visible certification type has that id or slug. | ErrorResponse |
| 409 | The organization already has a certification type with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id} Bearer tokenSoft-deletes a certification type by setting archived_at. Archived types stop being evaluated as requirements; awards already given stay readable. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The archived certification type. | CertificationType |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this type but does not hold certifications:write. | ErrorResponse |
| 404 | No visible certification type has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certifications/{cert_id}/rules Bearer tokenAdds one proof rule to a certification type; all rules of a type must be satisfied at once for it to be awardable. Note that date_range evidence counts its full elapsed wall-clock time towards subject_hours rules, so size thresholds accordingly. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| cert_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| rule_kind | string | yes | subject_hours: evidenced hours on the type's subject must reach threshold_hours. subject_record_count: completed records on the subject must reach threshold_count. evidence_document: a completed record on the subject must carry evidence of evidence_type. manual_sign_off: a permission holder must record a sign-off for the rule. quiz_pass: the user must have a passing attempt of the quiz named by quiz_id. external_course_pass: the user must have a passing grade of the external LMS course named by external_course_id. one of "subject_hours" | "subject_record_count" | "evidence_document" | "manual_sign_off" | "quiz_pass" | "external_course_pass" |
| label | string | no | Human label shown wherever the rule's progress is, e.g. "20 simulator hours". 1–200 characters |
| threshold_hours | number | no | Hours required, for subject_hours rules. Counted from quantity evidence in hours or minutes, plus the elapsed time of date_range evidence. |
| threshold_count | integer | no | Completed records required, for subject_record_count rules. |
| evidence_type | string | no | Evidence type that must be present, for evidence_document rules. one of "score" | "date_range" | "quantity" | "url" | "file" | "pass_fail" | "note" |
| quiz_id | string (uuid) | no | Quiz that must be passed, for quiz_pass rules. A quiz of the type's own organization. |
| external_course_id | string (uuid) | no | Catalogue id of the external LMS course that must be passed, for external_course_pass rules. A course of the type's own organization. Passing is decided by the course: its passing_score when set, the imported passed flag otherwise. |
| Status | Description | Body |
|---|---|---|
| 201 | The created rule. | CertificationRule |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this type but does not hold certifications:write. | ErrorResponse |
| 404 | No visible certification type has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-rules/{rule_id} Bearer tokenChanges a rule's label or configuration. The rule kind is immutable — delete and recreate a rule that should mean something else. Requires certifications:write over the type's owner. Progress is recomputed retroactively; award history is unaffected.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| rule_id | path | string (uuid) | yes | UUID id of the proof rule. |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null to clear the label. 1–200 characters |
| threshold_hours | number | no | Hours required, for subject_hours rules. Counted from quantity evidence in hours or minutes, plus the elapsed time of date_range evidence. |
| threshold_count | integer | no | Completed records required, for subject_record_count rules. |
| evidence_type | string | no | Evidence type that must be present, for evidence_document rules. one of "score" | "date_range" | "quantity" | "url" | "file" | "pass_fail" | "note" |
| quiz_id | string (uuid) | no | Quiz that must be passed, for quiz_pass rules. A quiz of the type's own organization. |
| external_course_id | string (uuid) | no | Catalogue id of the external LMS course that must be passed, for external_course_pass rules. A course of the type's own organization. Passing is decided by the course: its passing_score when set, the imported passed flag otherwise. |
| Status | Description | Body |
|---|---|---|
| 200 | The updated rule. | CertificationRule |
| 400 | The request body failed validation, or does not fit the rule's kind. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this rule but does not hold certifications:write. | ErrorResponse |
| 404 | No visible rule has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/certification-rules/{rule_id} Bearer tokenHard-deletes a rule. Every sign-off recorded against it cascades away with it. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| rule_id | path | string (uuid) | yes | UUID id of the proof rule. |
| Status | Description | Body |
|---|---|---|
| 200 | The deleted rule. | CertificationRule |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this rule but does not hold certifications:write. | ErrorResponse |
| 404 | No visible rule has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{org_id}/departments/{dept_id}/roles/{role_id}/required-certifications Bearer tokenLists the certification types required of everyone holding one department role, with each link's recertification scheme. Requires certifications:read; links whose type is outside the caller's scope are absent.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| dept_id | path | string | yes | Department UUID id or slug, resolved within the organization. |
| role_id | path | string | yes | Role UUID id or slug, resolved within the department. |
| Status | Description | Body |
|---|---|---|
| 200 | The role's requirement links. | RoleRequiredCertificationList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | The organization, department or role does not exist. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/organizations/{org_id}/departments/{dept_id}/roles/{role_id}/required-certifications Bearer tokenLinks a certification type to a department role, with a recertification scheme and an optional role-level initial due date every holder inherits. Requires certifications:write over the type's owner. The type must belong to the role's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org_id | path | string | yes | Organization UUID id or slug. |
| dept_id | path | string | yes | Department UUID id or slug, resolved within the organization. |
| role_id | path | string | yes | Role UUID id or slug, resolved within the department. |
| Field | Type | Required | Description |
|---|---|---|---|
| certification_type_id | string (uuid) | yes | A certification type of the same organization as the role's department. |
| recert_scheme | string | yes | none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year. one of "none" | "anniversary" | "fixed_calendar" |
| recert_interval_months | integer | no | Months between recertifications, for the anniversary scheme. |
| recert_anchor_month | integer | no | Calendar month (1-12) the certification falls due, for fixed_calendar. minimum 1 · maximum 12 |
| recert_anchor_day | integer | no | Day of the anchor month (1-31, clamped to the month's length), for fixed_calendar. minimum 1 · maximum 31 |
| initial_due_at | string (date-time) | no | When the *initial* certification falls due for every holder of the role. A member's per-person due date overrides it; omit to set no role-level deadline. |
| Status | Description | Body |
|---|---|---|
| 201 | The created requirement link. | RoleRequiredCertification |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold certifications:write over the type's owner. | ErrorResponse |
| 404 | The organization, department, role or certification type does not exist (or the type belongs to another organization). | ErrorResponse |
| 409 | The role already requires that certification type. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/role-required-certifications/{link_id} Bearer tokenReplaces the recertification scheme and role-level initial due date of one role⇄type link; the scheme, its configuration and the date always travel together — an omitted initial_due_at clears the role-level deadline. Requires certifications:write over the type's owner. Members' due dates are recomputed at read time.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| link_id | path | string (uuid) | yes | UUID id of the role⇄certification requirement link. |
| Field | Type | Required | Description |
|---|---|---|---|
| recert_scheme | string | yes | none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year. one of "none" | "anniversary" | "fixed_calendar" |
| recert_interval_months | integer | no | Months between recertifications, for the anniversary scheme. |
| recert_anchor_month | integer | no | Calendar month (1-12) the certification falls due, for fixed_calendar. minimum 1 · maximum 12 |
| recert_anchor_day | integer | no | Day of the anchor month (1-31, clamped to the month's length), for fixed_calendar. minimum 1 · maximum 31 |
| initial_due_at | string (date-time) | no | When the *initial* certification falls due for every holder of the role. A member's per-person due date overrides it; omit to set no role-level deadline. |
| Status | Description | Body |
|---|---|---|
| 200 | The updated requirement link. | RoleRequiredCertification |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this link but does not hold certifications:write. | ErrorResponse |
| 404 | No visible requirement link has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/role-required-certifications/{link_id} Bearer tokenHard-deletes one role⇄type link, like revoking a role grant: the requirement simply stops appearing. Awards and history are untouched. Requires certifications:write over the type's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| link_id | path | string (uuid) | yes | UUID id of the role⇄certification requirement link. |
| Status | Description | Body |
|---|---|---|
| 200 | The deleted requirement link. | RoleRequiredCertification |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this link but does not hold certifications:write. | ErrorResponse |
| 404 | No visible requirement link has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/certification-completion Bearer tokenTallies every current holder of the role against its required certification types: who holds a current award and is not past due, per type and overall, with the named roster. Requires the caller to hold a role in this role's manager chain, or be a superuser.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| organizationId | path | string (uuid) | yes | e.g. 6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7 |
| departmentId | path | string (uuid) | yes | e.g. 0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4 |
| roleId | path | string (uuid) | yes | e.g. 9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10 |
| Status | Description | Body |
|---|---|---|
| 200 | The role's aggregate completion, tallies and roster alike. | RoleCertificationCompletion |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller is neither in the role's manager chain nor a superuser. | ErrorResponse |
| 404 | The organization, department, or role does not exist there. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/team/certification-completion Bearer tokenThe aggregate completion of every role reachable walking the reporting lines down from the caller's unexpired roles, limited to roles that require at least one live certification type. Empty for a caller who manages nothing.
| Status | Description | Body |
|---|---|---|
| 200 | One aggregate per managed role that requires certifications. | TeamCertificationCompletionList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quiz-banks/{bank_id}/questions Bearer tokenThe bank's questions in authored order, correct answers included — this is the authoring read, so it requires quizzes:read over the bank's owner. Takers never see this shape; their attempts serve questions with the answers stripped.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| bank_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| Status | Description | Body |
|---|---|---|
| 200 | The bank's questions, oldest position first. | QuizQuestionList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible question bank has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quiz-banks/{bank_id}/questions Bearer tokenAppends one question to a bank, configured per kind: true_false with its correct_boolean; numeric with correct_number and tolerance; the rest with up to 10 options — multiple_choice (exactly one correct), multi_select (at least one, scored all-or-nothing), ordering (items in their correct order), short_answer (the accepted spellings, one or more) and matching (label/match_label pairs). The prompt is plaintext by default; prompt_format markdown renders it as Markdown, and pdf attaches a previously uploaded PDF (see the question-pdfs endpoint). Requires quizzes:write over the bank's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| bank_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| kind | string | yes | true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. one of "true_false" | "multiple_choice" | "multi_select" | "ordering" | "short_answer" | "numeric" | "matching" |
| prompt | string | yes | 1–2000 characters |
| prompt_format | string | no | Defaults to plaintext. one of "plaintext" | "markdown" | "pdf" |
| prompt_pdf | QuestionPromptPdf | no | — |
| explanation | string | no | Omit or send empty for no explanation. at most 2000 characters |
| correct_boolean | boolean | no | Required for true_false questions; omit for every other kind. |
| correct_number | number | no | numeric questions only. |
| tolerance | number | no | numeric questions only; 0 demands the exact value. minimum 0 |
| options | array of object | no | Up to 10 options in presentation order — at least two, or one accepted answer for short_answer. multiple_choice needs exactly one correct option; multi_select at least one; ordering lists items in their correct order; matching gives each option its match_label. 1–10 items |
| Status | Description | Body |
|---|---|---|
| 201 | The created question, options included. | QuizQuestion |
| 400 | The request body failed validation, does not fit the question kind, or names a PDF upload that is missing or not this bank's. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this bank but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible question bank has that id or slug. | ErrorResponse |
| 409 | That PDF upload is already attached to another question. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/quiz-banks/{bank_id}/question-pdfs Bearer tokenIssues a presigned URL to PUT one PDF straight to private blob storage, scoped to this bank, application/pdf and a 25 MB ceiling. Create (or update) the question with the returned pathname as prompt_pdf.pathname afterwards. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| bank_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| size_bytes | integer | yes | maximum 26214400 |
| Status | Description | Body |
|---|---|---|
| 201 | Where to PUT the file, and the pathname the question will point at. | QuestionPdfUploadTicket |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this bank but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible question bank has that id or slug. | ErrorResponse |
| 500 | The upload URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/quiz-questions/{question_id}/pdf Bearer tokenIssues a short-lived presigned GET for a pdf question's private blob — the authoring read. Requires quizzes:read over the bank's owner; takers fetch it through their attempt's question pdf endpoint instead.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| question_id | path | string (uuid) | yes | UUID id of the question. |
| Status | Description | Body |
|---|---|---|
| 200 | Where to fetch the PDF from, for the next five minutes. | DownloadTicket |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible question has that id, or it has no PDF attached. | ErrorResponse |
| 500 | The download URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/quiz-banks Bearer tokenLists the question banks the caller can read, newest first, with cursor pagination. Requires quizzes:read in scope; rows outside the caller's scope are simply absent.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| department | query | string | no | Department UUID id or slug, resolved within org (which is then required). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of question banks, and the cursor for the next. | QuizQuestionBankList |
| 400 | A filter did not resolve, or the cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quiz-banks Bearer tokenCreates a reusable question bank owned by an organization, or by one department within it. Banks are a shared library: any quiz of the organization may draw from them. Requires quizzes:write over the owner.
| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the bank belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a bank owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like fire-safety-basics. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created question bank. | QuizQuestionBank |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold quizzes:write over the owner. | ErrorResponse |
| 404 | The organization, or the department within it, does not exist. | ErrorResponse |
| 409 | The organization already has a question bank with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-banks/{bank_id} Bearer tokenOne question bank. Requires quizzes:read over the owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| bank_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The question bank. | QuizQuestionBank |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible question bank has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quiz-banks/{bank_id} Bearer tokenChanges a bank's slug, name or description. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| bank_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The updated question bank. | QuizQuestionBank |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this bank but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible question bank has that id or slug. | ErrorResponse |
| 409 | The organization already has a question bank with the new slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-banks/{bank_id} Bearer tokenSoft-deletes a question bank by setting archived_at. Its questions stop being drawn into new attempts through any quiz link; history is untouched. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| bank_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The archived question bank. | QuizQuestionBank |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this bank but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible question bank has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-questions/{question_id} Bearer tokenChanges a question's prompt, its format, explanation or answers; sending options replaces the whole option set, and sending prompt_pdf attaches or replaces the PDF (switching prompt_format away from pdf discards it). The kind is immutable. Submitted attempts keep the grades they were given — history is never re-graded — but their answer review renders the questions as they are now. Requires quizzes:write over the bank's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| question_id | path | string (uuid) | yes | UUID id of the question. |
| Field | Type | Required | Description |
|---|---|---|---|
| prompt | string | no | 1–2000 characters |
| prompt_format | string | no | Switching between plaintext and markdown re-renders the same prompt text. Switching to pdf needs prompt_pdf (unless one is already attached); switching away discards the attachment. one of "plaintext" | "markdown" | "pdf" |
| prompt_pdf | any | no | — |
| explanation | string | null | no | Send null to clear the explanation. at most 2000 characters |
| correct_boolean | boolean | no | For true_false questions only. |
| correct_number | number | no | numeric questions only. |
| tolerance | number | no | numeric questions only; 0 demands the exact value. minimum 0 |
| options | array of object | no | For questions with options only. Replaces the whole option set; answer review of attempts that saw the old options degrades accordingly. Grading of submitted attempts never changes. 1–10 items |
| Status | Description | Body |
|---|---|---|
| 200 | The updated question, options included. | QuizQuestion |
| 400 | The request body failed validation, does not fit the question's kind or format, or names a PDF upload that is missing or not this bank's. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this question but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible question has that id. | ErrorResponse |
| 409 | That PDF upload is already attached to another question. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/quiz-questions/{question_id} Bearer tokenHard-deletes a question nobody has attempted, along with its attached PDF if any. A question presented in any attempt is kept for history and answers 409 — archive it instead. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| question_id | path | string (uuid) | yes | UUID id of the question. |
| Status | Description | Body |
|---|---|---|
| 200 | The deleted question, as it was (options omitted). | QuizQuestion |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this question but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible question has that id. | ErrorResponse |
| 409 | The question has attempt history; archive it instead. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-questions/{question_id}/archive Bearer tokenSets archived_at: the question stops being drawn into new papers but stays readable in past attempts. Requires quizzes:write over the bank's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| question_id | path | string (uuid) | yes | UUID id of the question. |
| Status | Description | Body |
|---|---|---|
| 200 | The archived question, options included. | QuizQuestion |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this question but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible question has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id}/attempts Bearer tokenEvery attempt of the quiz, newest first, scores included — the results view for holders of quizzes:read over the quiz's owner. Expired in-progress attempts are finalized (auto-submitted as-is) before listing, so no background job is needed.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of attempts, and the cursor for the next. | QuizAttemptList |
| 400 | The cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quizzes/{quiz_id}/schedules Bearer tokenAdds one scheduled sitting of the quiz — live quizzes are takeable only inside an open sitting, and max_attempts counts per sitting, so recurring runs (say, yearly recertification) are just more sittings. Sittings of one quiz must not overlap. Sittings of async quizzes are accepted but ignored. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | no | Omit or send empty for an unlabelled sitting. 1–200 characters |
| opens_at | string (date-time) | yes | — |
| closes_at | string (date-time) | yes | — |
| Status | Description | Body |
|---|---|---|
| 201 | The scheduled sitting. | ScheduledQuiz |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 409 | The sitting would overlap another sitting of the quiz. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-schedules/{schedule_id} Bearer tokenChanges a sitting's label or window. Attempts already sat in it keep their recorded deadlines and grades; moving the close changes when their held-back results release. Requires quizzes:write over the quiz's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| schedule_id | path | string (uuid) | yes | UUID id of the scheduled sitting. |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null to clear the label. 1–200 characters |
| opens_at | string (date-time) | no | — |
| closes_at | string (date-time) | no | — |
| Status | Description | Body |
|---|---|---|
| 200 | The updated sitting. | ScheduledQuiz |
| 400 | The request body failed validation, or the merged window is inverted. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this sitting but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible sitting has that id. | ErrorResponse |
| 409 | The sitting would overlap another sitting of the quiz. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-schedules/{schedule_id} Bearer tokenRemoves a sitting nobody has sat. A sitting with attempts is history and answers 409 — move its window instead. Requires quizzes:write over the quiz's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| schedule_id | path | string (uuid) | yes | UUID id of the scheduled sitting. |
| Status | Description | Body |
|---|---|---|
| 200 | The deleted sitting, as it was. | ScheduledQuiz |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this sitting but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible sitting has that id. | ErrorResponse |
| 409 | The sitting has attempts; it is history and cannot be deleted. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id}/assignments Bearer tokenWho may see and take the quiz: assigned department roles (every holder) and individually assigned members. Requires quizzes:read over the quiz's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The quiz's assignments, oldest first. | QuizAssignmentList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quizzes/{quiz_id}/assignments Bearer tokenAssigns the quiz to a department role (every holder can take it) or to one directory member — exactly one of role_id / user_id. Both must belong to the quiz's own organization. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| role_id | string (uuid) | no | Assign to every holder of this department role. |
| user_id | string (uuid) | no | Assign to one member of the quiz's organization, by user id. |
| Status | Description | Body |
|---|---|---|
| 201 | The created assignment. | QuizAssignment |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | The quiz, role, or member does not exist in the quiz's organization. | ErrorResponse |
| 409 | The quiz is already assigned to that role or member. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-assignments/{assignment_id} Bearer tokenUnassigns a role or member from the quiz. Attempts already made are history and stay. Requires quizzes:write over the quiz's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| assignment_id | path | string (uuid) | yes | UUID id of the assignment. |
| Status | Description | Body |
|---|---|---|
| 200 | The removed assignment, as it was (display names omitted). | QuizAssignment |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible assignment has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id}/banks Bearer tokenReplaces the quiz's bank links; array order is presentation order, and draw_count bounds what a random_draw quiz pulls from each bank (omit for all). Banks must belong to the quiz's own organization. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| links | array of object | yes | Replaces the quiz's bank links; array order is presentation order. at most 50 items |
| Status | Description | Body |
|---|---|---|
| 200 | The quiz with its new bank links. | QuizDetail |
| 400 | The body failed validation, or a bank is not in the quiz's organization. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id}/subjects Bearer tokenReplaces the quiz's subject-matter tags — the subjects each taker's learning record is filed under. Subjects must belong to the quiz's own organization. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| subject_matter_ids | array of string (uuid) | yes | Replaces the quiz's subject-matter tags. at most 50 items |
| Status | Description | Body |
|---|---|---|
| 200 | The quiz with its new subject tags. | QuizDetail |
| 400 | The body failed validation, or a subject is not in the quiz's organization. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id}/publish Bearer tokenDraft → published: assignees can now see the quiz, and sit it inside one of its scheduled sittings (live) or at any time (async). Refused while the linked banks hold no live questions, or while a live quiz has no sitting scheduled. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The published quiz. | Quiz |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 409 | The quiz would present no questions, or a live quiz has no sitting. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id}/release-results Bearer tokenStamps results_released_at, making every taker's results visible — the manual release for quizzes that neither show results immediately nor have a closing window. Expired in-progress attempts are finalized first. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The quiz, with results released. | Quiz |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id} Bearer tokenOne quiz with its bank links, subject tags and scheduled sittings — the admin view. Requires quizzes:read over the owner. Takers read their assigned view at /quizzes/assigned.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The quiz, bank links and subjects included. | QuizDetail |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quizzes/{quiz_id} Bearer tokenChanges a quiz's configuration. Published quizzes stay editable — attempts are graded at submit time, so history never re-grades — but selection and limit changes apply to future attempts immediately. Sittings are managed at /quizzes/{quiz_id}/schedules. Requires quizzes:write over the owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| delivery_kind | string | no | live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply. one of "live" | "async" |
| time_limit_seconds | integer | null | no | Send null to remove the time limit. |
| selection_mode | string | no | fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled. one of "fixed" | "shuffle" | "random_draw" |
| pass_percentage | number | no | Score required to pass, as a percentage of the questions presented. minimum 0 · maximum 100 |
| max_attempts | integer | null | no | Send null for unlimited attempts. |
| show_results_immediately | boolean | no | — |
| show_correct_answers | boolean | no | — |
| Status | Description | Body |
|---|---|---|
| 200 | The updated quiz, bank links and subjects included. | QuizDetail |
| 400 | The request body failed validation, or the window rules were broken. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 409 | The organization already has a quiz with the new slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/{quiz_id} Bearer tokenSoft-deletes a quiz by setting archived_at: it disappears from assignees and cannot be sat; attempts and their learning records stay readable. Requires quizzes:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The archived quiz. | Quiz |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this quiz but does not hold quizzes:write. | ErrorResponse |
| 404 | No visible quiz has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes Bearer tokenLists the quizzes the caller can administer or read, newest first, with cursor pagination. Requires quizzes:read in scope; rows outside the caller's scope are simply absent. Takers list what is assigned to them at /quizzes/assigned instead.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| department | query | string | no | Department UUID id or slug, resolved within org (which is then required). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of quizzes, and the cursor for the next. | QuizList |
| 400 | A filter did not resolve, or the cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quizzes Bearer tokenCreates a quiz in draft, owned by an organization or one department within it. Question banks of the owning organization can be linked from the start via bank_links (or later via PUT /quizzes/{quiz_id}/banks); tag subjects and assign takers next, then publish. live quizzes are sat inside scheduled sittings (POST /quizzes/{quiz_id}/schedules) — the same quiz can be scheduled again and again; async ones are takeable whenever published. Requires quizzes:write over the owner.
| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the quiz belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a quiz owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like fire-safety-basics. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| delivery_kind | string | yes | live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply. one of "live" | "async" |
| time_limit_seconds | integer | no | Per-attempt time limit in seconds; a live sitting's close still caps it. |
| selection_mode | string | yes | fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled. one of "fixed" | "shuffle" | "random_draw" |
| pass_percentage | number | yes | Score required to pass, as a percentage of the questions presented. minimum 0 · maximum 100 |
| max_attempts | integer | no | Omit for unlimited attempts. |
| show_results_immediately | boolean | no | Defaults to true. |
| show_correct_answers | boolean | no | Defaults to false. |
| bank_links | array of object | no | Question banks to link from the start, in presentation order; each must belong to the owning organization. Omit to link banks later via PUT /quizzes/{quiz_id}/banks. at most 50 items |
| Status | Description | Body |
|---|---|---|
| 201 | The created quiz, in draft, with any bank links it was created with. | QuizDetail |
| 400 | The request body failed validation, or a requested bank is not in the owning organization. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold quizzes:write over the owner. | ErrorResponse |
| 404 | The organization, or the department within it, does not exist. | ErrorResponse |
| 409 | The organization already has a quiz with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quizzes/assigned Bearer tokenEvery published quiz assigned to the caller — individually, or through any unexpired grant of an assigned role — with where they stand on each: the current and next scheduled sittings, attempts used, the open attempt to resume, and the latest result once results are visible. Needs no permission grant: being assigned is the authorization.
| Status | Description | Body |
|---|---|---|
| 200 | The caller's assigned quizzes, newest first. | AssignedQuizList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quizzes/{quiz_id}/attempts Bearer tokenStarts an attempt of an assigned, published quiz that is takeable right now — inside an open sitting for live quizzes, any time for async ones. The paper is drawn per the quiz's selection mode, persisted, and served with the answers stripped. An attempt already in progress is returned instead of starting another (200, not 201). Refused outside a sitting, or once max_attempts is used up — counted within the current sitting for live quizzes, per quiz for async. Needs no permission grant: being assigned is the authorization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| quiz_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The attempt already in progress, resumed. | QuizAttemptDetail |
| 201 | The freshly started attempt and its paper. | QuizAttemptDetail |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No quiz assigned to the caller has that id or slug. | ErrorResponse |
| 409 | No sitting is open, no attempts remain, or the paper is empty. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-attempts/{attempt_id}/questions/{question_id}/pdf Bearer tokenIssues a short-lived presigned GET for the PDF of a pdf question this attempt presented — while sitting the attempt and in answer review alike. Readable by the taker themselves, and by holders of quizzes:read over the quiz. Carries no answers.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| attempt_id | path | string (uuid) | yes | UUID id of the attempt. |
| question_id | path | string (uuid) | yes | UUID id of the question. |
| Status | Description | Body |
|---|---|---|
| 200 | Where to fetch the PDF from, for the next five minutes. | DownloadTicket |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible attempt has that id, the question is not on its paper, or it has no PDF. | ErrorResponse |
| 500 | The download URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/quiz-attempts/{attempt_id}/review Bearer tokenThe attempt's paper with correct answers, the taker's responses and per-question grades. Takers see it only once the attempt is submitted, its results are visible, and the quiz is configured to show correct answers; holders of quizzes:read over the quiz always may. Questions render as they are *now* — an edit after the sitting changes the review, never the recorded grades.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| attempt_id | path | string (uuid) | yes | UUID id of the attempt. |
| Status | Description | Body |
|---|---|---|
| 200 | The graded paper, answers included. | QuizAttemptReview |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | Review is not (or not yet) available to the taker for this quiz. | ErrorResponse |
| 404 | No visible attempt has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quiz-attempts/{attempt_id} Bearer tokenThe attempt and its paper, answers stripped — the taking and resume view. Readable by the taker themselves, and by holders of quizzes:read over the quiz. An attempt past its deadline is finalized (auto-submitted as-is) by this read. Held-back results are nulled for the taker until released; permission holders always see them.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| attempt_id | path | string (uuid) | yes | UUID id of the attempt. |
| Status | Description | Body |
|---|---|---|
| 200 | The attempt and its paper. | QuizAttemptDetail |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible attempt has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/quiz-attempts/{attempt_id}/answers Bearer tokenSaves (or replaces) the taker's answer to one presented question, in the shape its kind takes: response_boolean (true_false), selected_option_ids (multiple_choice takes exactly one id; multi_select the chosen ids; ordering every presented item in the taker's arrangement), response_text (short_answer), response_number (numeric) or response_matches (matching; partial pair lists are fine). Only the taker may answer, only while the attempt is in progress and its deadline has not passed. Answers are not graded here; grading happens at submit.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| attempt_id | path | string (uuid) | yes | UUID id of the attempt. |
| Field | Type | Required | Description |
|---|---|---|---|
| question_id | string (uuid) | yes | — |
| response_boolean | boolean | no | — |
| selected_option_ids | array of string (uuid) | no | multiple_choice: exactly one id. multi_select: the chosen ids. ordering: every presented item id, in the taker's arrangement. 1–20 items |
| response_text | string | no | 1–500 characters |
| response_number | number | no | — |
| response_matches | array of ResponseMatch | no | 1–10 items |
| Status | Description | Body |
|---|---|---|
| 200 | The attempt with the saved answer. | QuizAttemptDetail |
| 400 | The answer does not fit the question's kind or options. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible attempt has that id, or the question is not on its paper. | ErrorResponse |
| 409 | The attempt is already submitted, or its deadline has passed. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/quiz-attempts/{attempt_id}/submit Bearer tokenGrades the attempt server-side, scores it against the quiz's pass mark, and reports it into the taker's learning record for the quiz. Unanswered questions score zero. Submitting twice is safe — the second call returns the already-graded attempt. Score and pass/fail travel back only when the quiz's rules make results visible.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| attempt_id | path | string (uuid) | yes | UUID id of the attempt. |
| Status | Description | Body |
|---|---|---|
| 200 | The submitted attempt; results nulled while withheld. | QuizAttempt |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible attempt has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys/mine Bearer tokenEvery unarchived journey whose audience covers the caller — through a held role, a department they hold a grant in (or one above it), or organization membership — with every level's derived state. Needs no permission grant: the audience is the gate. Locked levels are presentation only; nothing is enforced server-side.
| Status | Description | Body |
|---|---|---|
| 200 | The caller's journeys, each with derived progress. | MyJourneys |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/journeys/{journey_id}/progress Bearer tokenThe journey's level graph as it stands for the caller: which levels are complete, unlocked or locked, and where every certification stands. Readable by anyone the journey's audience covers, and by holders of certifications:read over its owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The journey as it stands for the caller. | JourneyProgress |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible journey has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/journeys/{journey_id}/attachment-uploads Bearer tokenIssues a presigned URL to PUT one file of any type straight to private blob storage, scoped to this journey, the declared content type and a 500 MB ceiling. Attach it with POST /journeys/{journey_id}/attachments and the returned pathname afterwards. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| content_type | string | yes | The file's content type; any type is allowed, but the upload pins this one. 1–200 characters |
| size_bytes | integer | yes | maximum 524288000 |
| Status | Description | Body |
|---|---|---|
| 201 | Where to PUT the file, and the pathname the attachment will point at. | DescriptionUploadTicket |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but lacks certifications:write. | ErrorResponse |
| 404 | No visible journey matches the path. | ErrorResponse |
| 500 | The upload URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/journeys/{journey_id}/attachments Bearer tokenCreates the attachment at the end of the authored order, pointing at a pathname an upload ticket was issued for (after PUTting the file there). The content type is read back from the store, never from the request. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | The pathname an upload ticket was issued for, after PUTting the file. at least 1 character |
| file_name | string | yes | 1–300 characters |
| label | string | no | Omit or send empty to show the file name instead. at most 200 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created attachment. | DescriptionAttachment |
| 400 | The body failed validation, or the pathname was not uploaded. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but lacks certifications:write. | ErrorResponse |
| 404 | No visible journey matches the path. | ErrorResponse |
| 409 | That upload is already attached. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/journeys/{journey_id}/attachments/{attachment_id}/download Bearer tokenIssues a short-lived presigned GET for the private attachment. Readable by anyone the journey's audience covers, and by holders of certifications:read over its owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | Where to fetch the file from, for the next five minutes. | DownloadTicket |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible journey matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The download URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/journeys/{journey_id}/attachments/{attachment_id} Bearer tokenChanges the attachment's label (null or empty clears it back to the file name) or its slot in the authored order. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null or empty to clear the label back to the file name. at most 200 characters |
| position | integer | no | New slot in the authored order. minimum 0 |
| Status | Description | Body |
|---|---|---|
| 200 | The updated attachment. | DescriptionAttachment |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but lacks certifications:write. | ErrorResponse |
| 404 | No visible journey matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys/{journey_id}/attachments/{attachment_id} Bearer tokenRemoves the attachment; the blob it pointed at is deleted best-effort. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 204 | The attachment is gone. | — |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but lacks certifications:write. | ErrorResponse |
| 404 | No visible journey matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journey-levels/{level_id}/attachment-uploads Bearer tokenIssues a presigned URL to PUT one file of any type straight to private blob storage, scoped to this level, the declared content type and a 500 MB ceiling. Attach it with POST /journey-levels/{level_id}/attachments and the returned pathname afterwards. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| level_id | path | string (uuid) | yes | UUID id of the level. |
| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| content_type | string | yes | The file's content type; any type is allowed, but the upload pins this one. 1–200 characters |
| size_bytes | integer | yes | maximum 524288000 |
| Status | Description | Body |
|---|---|---|
| 201 | Where to PUT the file, and the pathname the attachment will point at. | DescriptionUploadTicket |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this level but lacks certifications:write. | ErrorResponse |
| 404 | No visible level matches the path. | ErrorResponse |
| 500 | The upload URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/journey-levels/{level_id}/attachments Bearer tokenCreates the attachment at the end of the authored order, pointing at a pathname an upload ticket was issued for (after PUTting the file there). The content type is read back from the store, never from the request. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| level_id | path | string (uuid) | yes | UUID id of the level. |
| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | The pathname an upload ticket was issued for, after PUTting the file. at least 1 character |
| file_name | string | yes | 1–300 characters |
| label | string | no | Omit or send empty to show the file name instead. at most 200 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created attachment. | DescriptionAttachment |
| 400 | The body failed validation, or the pathname was not uploaded. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this level but lacks certifications:write. | ErrorResponse |
| 404 | No visible level matches the path. | ErrorResponse |
| 409 | That upload is already attached. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/journey-levels/{level_id}/attachments/{attachment_id}/download Bearer tokenIssues a short-lived presigned GET for the private attachment. Readable by anyone the level's journey's audience covers, and by holders of certifications:read over its owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| level_id | path | string (uuid) | yes | UUID id of the level. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| Status | Description | Body |
|---|---|---|
| 200 | Where to fetch the file from, for the next five minutes. | DownloadTicket |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible level matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The download URL could not be signed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/journey-levels/{level_id}/attachments/{attachment_id} Bearer tokenChanges the attachment's label (null or empty clears it back to the file name) or its slot in the authored order. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| level_id | path | string (uuid) | yes | UUID id of the level. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null or empty to clear the label back to the file name. at most 200 characters |
| position | integer | no | New slot in the authored order. minimum 0 |
| Status | Description | Body |
|---|---|---|
| 200 | The updated attachment. | DescriptionAttachment |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this level but lacks certifications:write. | ErrorResponse |
| 404 | No visible level matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journey-levels/{level_id}/attachments/{attachment_id} Bearer tokenRemoves the attachment; the blob it pointed at is deleted best-effort. Requires certifications:write.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| level_id | path | string (uuid) | yes | UUID id of the level. |
| attachment_id | path | string (uuid) | yes | UUID id of the attachment. |
| Status | Description | Body |
|---|---|---|
| 204 | The attachment is gone. | — |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this level but lacks certifications:write. | ErrorResponse |
| 404 | No visible level matches the path, or it has no such attachment. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys/{journey_id}/levels Bearer tokenCreates a level at the end of the authored order, with its prerequisite levels and certification contents in one call. A certification type may appear in at most one level per journey. Requires certifications:write over the journey's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| description_markdown | string | no | Omit or send empty for no long-form markdown description. at most 50000 characters |
| depends_on_level_ids | array of string (uuid) | no | Existing levels of the same journey this one depends on. The edges must keep the journey acyclic. Omit for a root level. at most 50 items |
| certification_type_ids | array of string (uuid) | no | Certification types of the same organization to place in this level, in order. A type may appear in at most one level per journey. at most 100 items |
| Status | Description | Body |
|---|---|---|
| 201 | The created level, with its prerequisites and contents. | JourneyLevel |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but does not hold certifications:write. | ErrorResponse |
| 404 | No visible journey has that id or slug, or a prerequisite level or certification type does not belong to it. | ErrorResponse |
| 409 | A certification type is already placed elsewhere in this journey. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journey-levels/{level_id} Bearer tokenRenames or re-describes a level; depends_on_level_ids replaces its whole prerequisite set (rejected when the edges would close a cycle), certification_type_ids its whole contents. Requires certifications:write over the journey's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| level_id | path | string (uuid) | yes | UUID id of the level. |
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| description_markdown | string | null | no | Send null to clear the long-form markdown description. at most 50000 characters |
| position | integer | no | minimum 0 |
| depends_on_level_ids | array of string (uuid) | no | Replaces the level's whole prerequisite set; must stay acyclic. at most 50 items |
| certification_type_ids | array of string (uuid) | no | Replaces the level's whole certification list, in the given order. at most 100 items |
| Status | Description | Body |
|---|---|---|
| 200 | The updated level, with its prerequisites and contents. | JourneyLevel |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but does not hold certifications:write. | ErrorResponse |
| 404 | No visible level has that id, or a prerequisite level or certification type does not belong to its journey. | ErrorResponse |
| 409 | The prerequisites would form a cycle, or a certification type is already placed elsewhere in this journey. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journey-levels/{level_id} Bearer tokenRemoves the level, its edges and its contents; levels that depended on it simply lose that prerequisite. Requires certifications:write over the journey's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| level_id | path | string (uuid) | yes | UUID id of the level. |
| Status | Description | Body |
|---|---|---|
| 204 | The level is gone. | — |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but does not hold certifications:write. | ErrorResponse |
| 404 | No visible level has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys/{journey_id}/assignments Bearer tokenPresents the journey to holders of a role, to everyone with a role grant in a department (or one nested beneath it), or to every member of the organization. The audience is presentation only — it never adds a certification requirement. Requires certifications:write over the journey's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| assignee_kind | string | yes | role: holders of role_id. department: everyone with a role grant in department_id or any department nested beneath it. organization: every member. one of "role" | "department" | "organization" |
| role_id | string (uuid) | no | Required for role assignments; must be a role of the journey's organization. |
| department_id | string (uuid) | no | Required for department assignments; forbidden for organization ones. |
| Status | Description | Body |
|---|---|---|
| 201 | The created assignment. | JourneyAssignment |
| 400 | The request body failed validation, or the kind/id pairing is off. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but does not hold certifications:write. | ErrorResponse |
| 404 | No visible journey has that id or slug, or the role or department is not in the journey's organization. | ErrorResponse |
| 409 | The journey already has that audience row. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys/{journey_id}/assignments/{assignment_id} Bearer tokenWithdraws the journey from that role, department or organization audience. Requires certifications:write over the journey's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| assignment_id | path | string (uuid) | yes | UUID id of the assignment. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 204 | The assignment is gone. | — |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but does not hold certifications:write. | ErrorResponse |
| 404 | No visible journey has that id or slug, or no such assignment on it. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys Bearer tokenLists the journeys the caller can administer, newest first, with cursor pagination. Requires certifications:read in scope; rows outside the caller's scope are simply absent. Trainees list what is assigned to them at /journeys/mine instead.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| department | query | string | no | Department UUID id or slug, resolved within org (which is then required). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of journeys, and the cursor for the next. | JourneyList |
| 400 | A filter did not resolve, or the cursor is malformed. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/journeys Bearer tokenCreates a journey, owned by an organization or one department within it. A journey arranges existing certification types into levels with prerequisite edges — a presentation overlay that never adds a requirement. Add levels and assign the audience next. Requires certifications:write over the owner.
| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the journey belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a journey owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like new-hire-path. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description_markdown | string | no | Omit or send empty for no markdown description. at most 50000 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created journey, with no levels yet. | Journey |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold certifications:write over the owner. | ErrorResponse |
| 404 | The organization, or the department within it, does not exist. | ErrorResponse |
| 409 | The organization already has a journey with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys/{journey_id} Bearer tokenThe journey, every level with its prerequisites and certification contents, and the audience — the authoring read. Requires certifications:read over the owner; trainees read their own derived view at /journeys/{journey_id}/progress instead.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The journey, its levels and its audience. | JourneyDetail |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible journey has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/journeys/{journey_id} Bearer tokenRenames, re-slugs or re-describes a journey. File attachments are managed through the /journeys/{journey_id}/attachments endpoints. Requires certifications:write over the owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description_markdown | string | null | no | Send null to clear the markdown description. at most 50000 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The updated journey. | Journey |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but does not hold certifications:write. | ErrorResponse |
| 404 | No visible journey has that id or slug. | ErrorResponse |
| 409 | The organization already has a journey with the new slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/journeys/{journey_id}/archive Bearer tokenRetires the journey: it stays readable to administrators but leaves every trainee's journey list. Idempotent. Requires certifications:write over the owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| journey_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The archived journey. | Journey |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this journey but does not hold certifications:write. | ErrorResponse |
| 404 | No visible journey has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/servers Bearer tokenLists one organization's external LMS servers, alphabetically. Requires external-lms:read over the organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | yes | Organization UUID id or slug whose catalogue to list. Required. |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| Status | Description | Body |
|---|---|---|
| 200 | The organization's servers. | ExternalLmsServerList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible organization has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/external-lms/servers Bearer tokenAdds a server to an organization's catalogue. Descriptive metadata only — the LRS never connects to it. Requires external-lms:write over the organization.
| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string (uuid) | yes | Organization whose catalogue the server belongs to. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like moodle-eu. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| base_url | string (uri) | no | at most 2000 characters |
| lms_kind | string | no | Free-text kind of the server, e.g. "moodle" or "scorm-cloud"; informational. 1–100 characters |
| Status | Description | Body |
|---|---|---|
| 201 | The created server. | ExternalLmsServer |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold external-lms:write over the organization. | ErrorResponse |
| 404 | No visible organization has that id or slug. | ErrorResponse |
| 409 | The organization already has a server with that slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/servers/{server_id}/grades/import Bearer tokenBulk variant of the grades import: one uploaded gradebook CSV instead of a JSON batch, with the same one-outcome-per-row semantics — malformed cells fail their row, not the file, so a fixed export can simply be uploaded again (already-imported rows come back as duplicate). Requires records:write over the organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| file | string (binary) | yes | CSV file, at most 1 MB: a header row naming an `external_course_id`, `score` (0-100) and `completed_at` (ISO date) column, plus an `external_user_id` and/or `learner_user_id` column, in any order; `passed` and `external_attempt_id` columns are optional. Header matching is case-insensitive and extra columns are ignored. |
| Status | Description | Body |
|---|---|---|
| 200 | One outcome per data row, in file order. | IngestExternalGradesResult |
| 400 | The file is not parseable CSV, a required column is missing from the header, the file has no (or too many) data rows, or the server is archived. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold records:write over the organization. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 413 | The file is larger than 1 MB. | ErrorResponse |
| 500 | The database could not be reached. | ErrorResponse |
/api/external-lms/servers/{server_id}/grades Bearer tokenThe server's most recently imported grades, newest first. Requires external-lms:read over the server's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| Status | Description | Body |
|---|---|---|
| 200 | The grades, newest first. | ExternalCourseGradeList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/external-lms/servers/{server_id}/grades Bearer tokenImports a batch of grades from this server. Each created grade becomes a completed learning record (owned by the organization) carrying the score as evidence, plus a queryable grade row. Rows succeed or fail one by one — re-submitting a batch after fixing a mapping is the normal workflow; already-imported rows come back as duplicate. Learners are identified by external_user_id through the server's user mappings, or directly by user_id. Requires records:write over the organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| grades | array of ExternalGradeRow | yes | 1–1000 items |
| Status | Description | Body |
|---|---|---|
| 200 | One outcome per submitted row, in row order. | IngestExternalGradesResult |
| 400 | The request body failed validation, or the server is archived. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold records:write over the organization. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 500 | The database could not be reached. | ErrorResponse |
/api/external-lms/servers/{server_id}/user-mappings Bearer tokenLists how the server's external user identities resolve to directory members. Requires external-lms:read over the server's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The server's mappings, by external user id. | ExternalLmsUserMappingList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/external-lms/servers/{server_id}/user-mappings Bearer tokenMaps one external user identity to a directory member; writing an existing identity repoints it. Grades already imported keep the member they resolved to at import time. Requires external-lms:write over the server's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| external_user_id | string | yes | The LMS's own identity of the learner. Writing an existing one repoints it. 1–200 characters |
| user_id | string (uuid) | yes | users.id of the directory member the identity resolves to. |
| Status | Description | Body |
|---|---|---|
| 200 | The server's mappings after the write. | ExternalLmsUserMappingList |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold external-lms:write over the organization. | ErrorResponse |
| 404 | No visible server has that id or slug, or the user is not in the organization's directory. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/servers/{server_id}/user-mappings/{external_user_id} Bearer tokenUnmaps one external user identity; grades already imported through it are untouched. Requires external-lms:write over the server's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| external_user_id | path | string | yes | The LMS's own identity of the learner, URL-encoded. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The server's mappings after the delete. | ExternalLmsUserMappingList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold external-lms:write over the organization. | ErrorResponse |
| 404 | No visible server has that id or slug, or no mapping has that identity. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/servers/{server_id}/courses Bearer tokenLists one server's catalogue courses, alphabetically. Requires external-lms:read over the server's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| Status | Description | Body |
|---|---|---|
| 200 | The server's courses. | ExternalLmsCourseList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/external-lms/servers/{server_id}/courses Bearer tokenAdds one course, keyed by the LMS's own course identifier — the id grades arrive under. Requires external-lms:write over the server's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| external_course_id | string | yes | The LMS's own identifier of the course. Unique within the server; immutable. 1–200 characters |
| name | string | yes | 1–200 characters |
| description | string | no | at most 2000 characters |
| course_url | string (uri) | no | at most 2000 characters |
| passing_score | number | no | Score required to pass, 0-100. Omit to let the imported passed flag decide. minimum 0 · maximum 100 |
| subject_matter_id | string (uuid) | no | Subject matter of the type's own organization to tag imported grade records with. |
| Status | Description | Body |
|---|---|---|
| 201 | The created course. | ExternalLmsCourse |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold external-lms:write over the organization. | ErrorResponse |
| 404 | No visible server has that id or slug, or the subject matter is not the organization's own. | ErrorResponse |
| 409 | The server already catalogues a course with that external id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/servers/{server_id} Bearer tokenRequires external-lms:read over the server's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The server. | ExternalLmsServer |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/external-lms/servers/{server_id} Bearer tokenChanges a server's name, description, URL or kind. The slug is immutable. Requires external-lms:write over the organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| base_url | string (uri) | null | no | Send null to clear the URL. at most 2000 characters |
| lms_kind | string | null | no | Send null to clear the kind. 1–100 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The updated server. | ExternalLmsServer |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this server but does not hold external-lms:write. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/servers/{server_id} Bearer tokenSoft-deletes a server by setting archived_at. Its courses, mappings and imported grades stay readable, but the server stops taking grades. Requires external-lms:write over the organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| server_id | path | string | yes | UUID id, or a slug resolved within the organization given by the org query parameter. |
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| Status | Description | Body |
|---|---|---|
| 200 | The archived server. | ExternalLmsServer |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this server but does not hold external-lms:write. | ErrorResponse |
| 404 | No visible server has that id or slug. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/courses/{course_id} Bearer tokenRequires external-lms:read over the course's organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string (uuid) | yes | UUID id of the catalogue course. |
| Status | Description | Body |
|---|---|---|
| 200 | The course. | ExternalLmsCourse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible course has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/external-lms/courses/{course_id} Bearer tokenChanges a course's name, description, URL, pass mark or subject tag. The external course id is immutable — grades key on it. Requires external-lms:write over the organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string (uuid) | yes | UUID id of the catalogue course. |
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | at most 2000 characters |
| course_url | string (uri) | null | no | Send null to clear the URL. at most 2000 characters |
| passing_score | number | null | no | Send null to clear the pass mark (the imported flag then decides). minimum 0 · maximum 100 |
| subject_matter_id | string (uuid) | null | no | Send null to stop tagging imported records. |
| Status | Description | Body |
|---|---|---|
| 200 | The updated course. | ExternalLmsCourse |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this course but does not hold external-lms:write. | ErrorResponse |
| 404 | No visible course has that id, or the subject matter is not the organization's own. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/external-lms/courses/{course_id} Bearer tokenSoft-deletes a course by setting archived_at. Imported grades stay, but the course stops taking new ones. Requires external-lms:write over the organization.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string (uuid) | yes | UUID id of the catalogue course. |
| Status | Description | Body |
|---|---|---|
| 200 | The archived course. | ExternalLmsCourse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller can read this course but does not hold external-lms:write. | ErrorResponse |
| 404 | No visible course has that id. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/scorm-courses/available Bearer tokenLists the live SCORM courses the caller may take, each with their own attempt state: organization-owned courses of every organization whose directory names them, and department-owned courses of departments they hold a role in. Needs no permission grant: the course's audience is the authorization.
| Status | Description | Body |
|---|---|---|
| 200 | The caller's available courses. | AvailableScormCourseList |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/scorm-courses/{course_id}/attempts Bearer tokenPrepares a session of the caller's attempt: resumes the attempt in progress (its saved CMI data seeds the runtime, and its accumulated time rolls forward as the new session's base), or starts attempt n+1 when none is open — immediately with restart: true, which closes the open attempt first. The response carries everything the in-page runtime needs, including the signed content URL the module's iframe loads. Needs no permission grant: the course's audience is the authorization — organization membership, narrowed to the owning department's role holders for department-owned courses.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string (uuid) | yes | UUID id of the SCORM course. |
| Field | Type | Required | Description |
|---|---|---|---|
| restart | boolean | no | True closes any attempt in progress and starts a fresh one; the default resumes it. |
| Status | Description | Body |
|---|---|---|
| 200 | The attempt already in progress, resumed. | ScormLaunch |
| 201 | A freshly started attempt. | ScormLaunch |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No course available to the caller has that id. | ErrorResponse |
| 409 | A concurrent launch is already starting an attempt; retry. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/scorm-attempts/{attempt_id} Bearer tokenSaves what the module has reported: the full CMI element map replaces the attempt's saved copy, and the summary the row carries — completion, success, scores, location, accumulated time — is re-derived from it server-side. The first commit that reports the attempt complete also files the result into the learner's records. The runtime calls this on every LMSCommit/Commit and on LMSFinish/Terminate. Only the attempt's own learner may commit; not their attempt is indistinguishable from a missing one.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| attempt_id | path | string (uuid) | yes | UUID id of the attempt. |
| Field | Type | Required | Description |
|---|---|---|---|
| cmi | object | yes | SCORM CMI elements by name, e.g. cmi.core.lesson_status. |
| api_flavor | string | null | yes | one of "scorm-1.2" | "scorm-2004" | null |
| terminated | boolean | yes | True once the module has called LMSFinish / Terminate this session. |
| Status | Description | Body |
|---|---|---|
| 200 | The attempt with its freshly derived summary. | ScormAttempt |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No attempt of the caller's has that id. | ErrorResponse |
| 409 | The attempt is closed; launch the course again for a fresh one. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/scorm-content/{ticket}/{asset_path} PublicServes one file out of a SCORM course's package zip: the launch page the player's iframe loads, and every asset it references by relative URL. The signed ticket segment is the authorization — POST /scorm-courses/{course_id}/attempts mints it into content_url after checking the caller may take the course — so no bearer token is required (module asset requests cannot carry one). Tickets are scoped to one course and expire with the session.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| ticket | path | string | yes | Signed, course-scoped content ticket — the launch response's content_url carries it. |
| asset_path | path | string | yes | Package-relative path of the file, spanning multiple path segments (the launch page's relative asset URLs resolve here on their own). |
| Status | Description | Body |
|---|---|---|
| 200 | The file's bytes, served with the package entry's own content type. | — |
| 400 | The asset path is missing or malformed. | ErrorResponse |
| 403 | The content ticket is invalid or has expired; relaunch the course. | ErrorResponse |
| 404 | The course is gone, or no file at that path exists in its package. | ErrorResponse |
| 500 | The course content could not be served. | ErrorResponse |
| 502 | The course package could not be read from storage. | ErrorResponse |
/api/scorm-courses/{course_id}/attempts Bearer tokenLists every learner's attempts of one course, newest first, one page at a time — each joined with the learner's current profile. Requires scorm:read over the course's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string (uuid) | yes | UUID id of the SCORM course. |
| limit | query | integer | no | Page size, 1-200. Defaults to 50. |
| cursor | query | string | no | Opaque cursor from a previous page's next_cursor. |
| Status | Description | Body |
|---|---|---|
| 200 | One page of the course's attempts. | ScormCourseAttemptList |
| 400 | The cursor is not one this API issued. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible course has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/scorm-courses/uploads Bearer tokenIssues a presigned upload URL for one SCORM package (.zip), bypassing the request-body limit by sending the bytes straight to the private blob store. The signed ticket pins a server-chosen pathname, zip content types and the package size cap. The staged upload becomes a course only through POST /scorm-courses, which validates the package first. Requires scorm:write over the intended owner — the organization, or the owning department within it when one is named.
| Field | Type | Required | Description |
|---|---|---|---|
| org | string | yes | Organization UUID id or slug the course will belong to. at least 1 character |
| department | string | no | UUID id or slug of the owning department within the organization; omit for a course owned by the organization directly. at least 1 character |
| file_name | string | yes | 1–255 characters |
| size_bytes | integer | yes | Declared package size; the upload token enforces the cap. maximum 52428800 |
| Status | Description | Body |
|---|---|---|
| 201 | The presigned upload ticket. | ScormPackageUploadTicket |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold scorm:write over the intended owner. | ErrorResponse |
| 404 | No visible organization has that id or slug, or the department is not one of the organization's own. | ErrorResponse |
| 500 | The upload ticket could not be issued. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/scorm-courses Bearer tokenLists the hosted SCORM courses the caller's scorm:read scope covers, newest first, optionally filtered to one organization or one owning department within it. Learners list what they can take through GET /scorm-courses/available instead.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| org | query | string | no | Organization UUID id or slug to filter by (and to resolve slug refs in). |
| department | query | string | no | Department UUID id or slug, resolved within org (which is then required). |
| include_archived | query | string | no | Include archived rows. Defaults to false. |
| Status | Description | Body |
|---|---|---|
| 200 | The visible courses. | ScormCourseList |
| 400 | The department filter was given without an org filter. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible organization has that id or slug, or the department is not one of the organization's own. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/scorm-courses Bearer tokenCompletes a package upload: validates the staged zip (it must contain an imsmanifest.xml whose launch resource exists in the archive), captures the launch path, SCORM version, manifest objectives and mastery score, and creates the course — owned by the organization, or by the owning department named within it. Requires scorm:write over that owner. An invalid package is rejected and its staged blob deleted.
| Field | Type | Required | Description |
|---|---|---|---|
| org | string | yes | Organization UUID id or slug the course belongs to. at least 1 character |
| department | string | no | UUID id or slug of the owning department within the organization; omit for a course owned by the organization directly. Ownership scopes administration and limits taking the course to the department's role holders. at least 1 character |
| pathname | string | yes | matches ^scorm-courses\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[^/]{1,100}$ |
| file_name | string | yes | 1–255 characters |
| name | string | no | Defaults to the title in the package manifest, then the file name. at most 200 characters |
| description | string | no | at most 5000 characters |
| subject_matter_id | string (uuid) | no | — |
| Status | Description | Body |
|---|---|---|
| 201 | The registered course. | ScormCourse |
| 400 | The request body failed validation, no upload exists at the pathname, or the package is not a runnable SCORM module. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold scorm:write over the intended owner. | ErrorResponse |
| 404 | No visible organization has that id or slug, or the department or subject matter is not the organization's own. | ErrorResponse |
| 409 | That uploaded package is already registered. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/scorm-courses/{course_id} Bearer tokenReads one course. Requires scorm:read over the course's owner — its owning department when it has one, its organization otherwise.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string (uuid) | yes | UUID id of the SCORM course. |
| Status | Description | Body |
|---|---|---|
| 200 | The course. | ScormCourse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 404 | No visible course has that id. | ErrorResponse |
| 500 | The database could not be read. | ErrorResponse |
/api/scorm-courses/{course_id} Bearer tokenChanges a course's name, description or subject matter, or archives/restores it. The package itself is immutable — new content is a new course. Attempts are history, so there is no delete; archiving hides the course from learners. Requires scorm:write over the course's owner.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string (uuid) | yes | UUID id of the SCORM course. |
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | at most 5000 characters |
| subject_matter_id | string (uuid) | null | no | — |
| archived | boolean | no | True archives the course (hidden from learners); false restores it. |
| Status | Description | Body |
|---|---|---|
| 200 | The updated course. | ScormCourse |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller does not hold scorm:write over the organization. | ErrorResponse |
| 404 | No visible course has that id, or the subject matter is not the organization's own. | ErrorResponse |
| 500 | The database could not be reached, or the write failed. | ErrorResponse |
/api/notifications/certification-requirements Bearer tokenBuilds a digest of certification requirements grouped into due windows (overdue, next 10/30/60 days), renders it to HTML + plaintext, validates it against the @schemavaults/send-email request schema, and submits it to the configured transactional mail server. `audience: trainee` covers the user's own requirements; `audience: manager` covers the trainees holding roles the user manages. Superusers only — this is the manual trigger behind the admin notifications page; scheduled sending will reuse the same pipeline. Use `dry_run` to validate without sending.
| Field | Type | Required | Description |
|---|---|---|---|
| user | string (uuid) | yes | `users.id` of the user the digest is about — and, unless `email` overrides it, the recipient (resolved from their account's profile). |
| audience | string | yes | Whose deadlines the digest covers: `trainee` — the user's own certification requirements; `manager` — the requirements of every trainee holding a role the user manages (scoped to those managed roles). one of "trainee" | "manager" |
| string (email) | no | Send to this address instead of the user's account email. | |
| dry_run | boolean | no | Build, render and validate the email without contacting the mail server. Defaults to false. |
| include_empty | boolean | no | Send an "all clear" email even when no requirement is overdue or due within 60 days. Defaults to false (such digests are skipped). |
| Status | Description | Body |
|---|---|---|
| 200 | The digest was sent, validated (dry_run), or skipped as empty. | CertificationNotificationResult |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The caller is not a superuser. | ErrorResponse |
| 404 | No recipient email address could be resolved for the user. | ErrorResponse |
| 500 | The digest could not be built or rendered. | ErrorResponse |
| 502 | The mail server rejected the message or could not be reached. | ErrorResponse |
/api/branding Bearer tokenThe friendly name and logo this deployment shows in place of the Botree defaults. Null fields mean the default is in use.
| Status | Description | Body |
|---|---|---|
| 200 | The current branding. | DeploymentBranding |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 500 | The deployment branding could not be read. | ErrorResponse |
/api/branding Bearer tokenSets the name shown in the header, footer, page titles and notification emails. Send null or an empty string to restore the product name. The footer keeps a "Powered by Botree LRS" credit while a custom name or logo is set. Requires a superuser's access token.
| Field | Type | Required | Description |
|---|---|---|---|
| display_name | string | null | yes | The friendly name to show; send null or an empty string to restore the default. at most 100 characters |
| Status | Description | Body |
|---|---|---|
| 200 | The updated branding. | DeploymentBranding |
| 400 | The request body failed validation. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 500 | The deployment branding could not be written to. | ErrorResponse |
/api/branding/logo PublicThe uploaded logo's bytes, with its content type. Public — the header and footer load it as an image — and 404 while the Botree artwork is in use. The pages append a version query for cache-busting; it is ignored here.
| Status | Description | Body |
|---|---|---|
| 200 | The image, served with the upload's content type. | — |
| 404 | No logo has been uploaded. | ErrorResponse |
| 500 | The deployment branding could not be read. | ErrorResponse |
| 502 | The logo could not be read from storage. | ErrorResponse |
/api/branding/logo Bearer tokenReplaces the logo shown in the header and footer with the image in the file part: PNG, JPEG, WebP, GIF or SVG, at most 2 MB. Square artwork fits best. Requires a superuser's access token.
| Field | Type | Required | Description |
|---|---|---|---|
| file | string (binary) | yes | The logo image, as a file part: PNG, JPEG, WebP, GIF or SVG, at most 2 MB. Square artwork fits the header best. |
| Status | Description | Body |
|---|---|---|
| 200 | The updated branding, its logo URL pointing at the new image. | DeploymentBranding |
| 400 | The file part is missing, not an accepted image type, or too large. | ErrorResponse |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 500 | The image could not be stored, or the write failed. | ErrorResponse |
| 503 | File storage is not configured on this deployment. | ErrorResponse |
/api/branding/logo Bearer tokenRestores the Botree artwork in the header and footer. Requires a superuser's access token.
| Status | Description | Body |
|---|---|---|
| 200 | The updated branding, with no logo. | DeploymentBranding |
| 401 | The access token is missing or invalid. | ErrorResponse |
| 403 | The access token's subject is not a superuser. | ErrorResponse |
| 500 | The deployment branding could not be written to. | ErrorResponse |
Request and response bodies referenced by the endpoints above.
| Field | Type | Required | Description |
|---|---|---|---|
| active | boolean | yes | one of true |
| subject | string | no | — |
| client_id | string | no | — |
| issuer | string | no | — |
| audience | array of string | yes | — |
| scopes | array of string | yes | — |
| issued_at | integer | no | Seconds since the epoch. |
| expires_at | integer | no | Seconds since the epoch. |
| validated_by | string | yes | Whether the token was validated locally against the JWKS or by introspection. one of "jwks" | "introspection" |
{
"active": true,
"subject": "usr_123",
"client_id": "botree-lrs",
"issuer": "https://auth.botreeinc.com",
"audience": [
"string"
],
"scopes": [
"lrs:read"
],
"issued_at": 0,
"expires_at": 0,
"validated_by": "jwks"
}| Field | Type | Required | Description |
|---|---|---|---|
| error | string | yes | — |
| error_description | string | yes | — |
{
"error": "invalid_token",
"error_description": "The access token has expired"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | Unique across all organizations. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
{
"slug": "acme-corporation",
"name": "ACME Corporation",
"description": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | Unique across all organizations. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null or empty to clear the description. at most 2000 characters |
{
"slug": "string",
"name": "string",
"description": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| parent_department_id | string (uuid) | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"parent_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| parent_department_id | string (uuid) | no | Nests the new department under one in the same organization. |
{
"slug": "quality-assurance",
"name": "Quality Assurance",
"description": "string",
"parent_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null or empty to clear the description. at most 2000 characters |
| parent_department_id | string (uuid) | null | no | Moves the department under another in the same organization — never itself or anything nested beneath it. Send null to make it top level. |
{
"slug": "string",
"name": "string",
"description": "string",
"parent_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| department_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| permissions | array of string | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"permissions": [
"string"
],
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | Unique within the department. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| permissions | array of string | no | The permissions the role grants, from the set the code understands. Duplicates are collapsed; an empty array grants nothing. at most 13 items · defaults to [] |
{
"slug": "reviewer",
"name": "Reviewer",
"description": "string",
"permissions": [
"records:read",
"records:write"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | Unique within the department. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null or empty to clear the description. at most 2000 characters |
| permissions | array of string | no | Replaces the whole permission set, from the set the code understands. Duplicates are collapsed; an empty array grants nothing. at most 13 items |
{
"slug": "string",
"name": "string",
"description": "string",
"permissions": [
"records:read",
"records:write"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| total_rows | integer | yes | Data rows in the file, not counting the header. |
| added_count | integer | yes | Members added to the directory by this import. |
| skipped_count | integer | yes | Rows skipped because their subject was already in the directory. |
| skipped_subjects | array of string | yes | The file's subjects whose accounts were already in the directory, in file order; their existing accounts and profiles are untouched. |
{
"total_rows": 25,
"added_count": 23,
"skipped_count": 2,
"skipped_subjects": [
"auth0|507f1f77bcf86cd799439011"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| provider_id | string | yes | Sign-in provider UUID id or slug; every row's user_id is the OIDC `sub` claim the member will present at this provider. at least 1 character |
| file | string (binary) | yes | CSV file, at most 1 MB: a header row naming a `user_id` (the OIDC `sub` claim at the named provider; `user_subject`, `subject` and `sub` are accepted too), `name` (or `display_name`) and `email` column in any order, then one member per row. Header matching is case-insensitive and extra columns are ignored. A member's name and email cells may be left blank; they seed a newly created account's profile. |
{
"provider_id": "acme-sso",
"file": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | The member's account id (`users.id`). |
| display_name | string | null | yes | The member's canonical profile, refreshed from their sign-ins. |
| string | null | yes | — | |
| kind | string | yes | `machine` marks accounts created from client-credentials tokens. one of "human" | "machine" |
| active_role_count | integer | yes | Roles the member currently holds here; expired grants are not counted. |
| created_at | string (date-time) | yes | — |
{
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f",
"display_name": "string",
"email": "string",
"kind": "human",
"active_role_count": 2,
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | no | The member's account id (`users.id`). Mutually exclusive with provider_id/subject. |
| provider_id | string | no | Sign-in provider UUID id or slug. at least 1 character |
| subject | string | no | OIDC `sub` claim the member will present at that provider. 1–255 characters |
| display_name | string | no | Starting profile for a newly created account; ignored for an existing one. 1–300 characters |
| string | no | 1–300 characters |
{
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"provider_id": "acme-sso",
"subject": "auth0|507f1f77bcf86cd799439011",
"display_name": "string",
"email": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | The member's account id (`users.id`). |
| display_name | string | null | yes | The member's canonical profile, refreshed from their sign-ins. |
| string | null | yes | — | |
| kind | string | yes | `machine` marks accounts created from client-credentials tokens. one of "human" | "machine" |
| active_role_count | integer | yes | Roles the member currently holds here; expired grants are not counted. |
| created_at | string (date-time) | yes | — |
| roles | array of DirectoryUserRole | yes | Every grant the member holds here, expired ones included. |
{
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f",
"display_name": "string",
"email": "string",
"kind": "human",
"active_role_count": 2,
"created_at": "2026-01-01T00:00:00.000Z",
"roles": [
{
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"role_description": "string",
"permissions": [
"records:read",
"records:write"
],
"granted_by": "string",
"granted_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| department_id | string (uuid) | yes | — |
| department_slug | string | yes | — |
| department_name | string | yes | — |
| role_id | string (uuid) | yes | — |
| role_slug | string | yes | — |
| role_name | string | yes | — |
| role_description | string | null | yes | — |
| permissions | array of string | yes | — |
| granted_by | string | null | yes | `users.id` of whoever granted the role; null when unrecorded. |
| granted_at | string (date-time) | yes | — |
| expires_at | string (date-time) | null | yes | When the grant lapses; null for an open-ended one. |
{
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"role_description": "string",
"permissions": [
"records:read",
"records:write"
],
"granted_by": "string",
"granted_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| department_id | string | yes | Department UUID id or slug, resolved within the organization. at least 1 character |
| role_id | string | yes | Role UUID id or slug, resolved within the department. at least 1 character |
| expires_at | string (date-time) | no | When the grant lapses; omit for an open-ended one. Must be in the future. |
{
"department_id": "quality-assurance",
"role_id": "reviewer",
"expires_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| expires_at | string (date-time) | null | yes | New expiry of the grant; null makes it open-ended. |
{
"expires_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| managers | array of RoleManagerEdge | yes | The roles managing this role, each edge's far side resolved. |
| subordinates | array of RoleManagerEdge | yes | The roles this role manages, each edge's far side resolved. |
{
"managers": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role": {
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string"
},
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"subordinates": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role": {
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string"
},
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| role | RoleManagerRef | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role": {
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string"
},
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| role_id | string (uuid) | yes | — |
| role_slug | string | yes | — |
| role_name | string | yes | — |
| department_id | string (uuid) | yes | — |
| department_slug | string | yes | — |
| department_name | string | yes | — |
{
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| manager_role_id | string (uuid) | yes | The role to make a manager of this one. Any role of the same organization except this role itself, or one that would close a cycle. |
{
"manager_role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| archived_at | string (date-time) | null | yes | When set, the record is retired and should not gain new children. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| archived_at | string (date-time) | null | yes | When set, the record is retired and should not gain new children. |
| created_at | string (date-time) | yes | — |
| departments | array of DepartmentSummary | yes | Every department in the organization, nested ones included, ordered by name. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z",
"departments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"parent_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| parent_department_id | string (uuid) | null | yes | — |
| archived_at | string (date-time) | null | yes | When set, the record is retired and should not gain new children. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"parent_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| department_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| permissions | array of string | yes | — |
| member_count | integer | yes | Users currently holding the role; expired grants are not counted. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"permissions": [
"records:read",
"records:write"
],
"member_count": 3,
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| providers | array of SignInProvider | yes | — |
{
"providers": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "acme-sso",
"display_name": "Acme SSO",
"issuer_url": "https://auth.acme.com",
"client_id": "string",
"enabled": true,
"client_auth_method": "client_secret_basic",
"client_secret_env": "OIDC_ACME_CLIENT_SECRET",
"client_private_key_env": "string",
"client_assertion_algorithm": "string",
"client_key_id": "string",
"audience": "string",
"login_scope": "openid profile email",
"access_token_algorithms": [
"RS256"
],
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| slug | string | yes | — |
| display_name | string | yes | Label shown on the sign-in picker. |
| issuer_url | string | yes | — |
| client_id | string | yes | — |
| enabled | boolean | yes | Disabled providers keep their identities but accept no sign-ins or tokens. |
| client_auth_method | string | yes | one of "client_secret_basic" | "client_secret_post" | "client_secret_jwt" | "private_key_jwt" | "none" |
| client_secret_env | string | null | yes | NAME of the env var holding the client secret; the secret itself never leaves the environment. |
| client_private_key_env | string | null | yes | NAME of the env var holding the PKCS#8 private key, for private_key_jwt. |
| client_assertion_algorithm | string | null | yes | — |
| client_key_id | string | null | yes | — |
| audience | string | null | yes | Expected `aud` of incoming access tokens; null skips the audience check. |
| login_scope | string | yes | — |
| access_token_algorithms | array of string | yes | Accepted JWS algorithms for incoming access tokens; empty means the defaults. |
| created_at | string (date-time) | yes | — |
| updated_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "acme-sso",
"display_name": "Acme SSO",
"issuer_url": "https://auth.acme.com",
"client_id": "string",
"enabled": true,
"client_auth_method": "client_secret_basic",
"client_secret_env": "OIDC_ACME_CLIENT_SECRET",
"client_private_key_env": "string",
"client_assertion_algorithm": "string",
"client_key_id": "string",
"audience": "string",
"login_scope": "openid profile email",
"access_token_algorithms": [
"RS256"
],
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | yes | at most 100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| display_name | string | yes | 1–200 characters |
| issuer_url | string (uri) | yes | at most 500 characters |
| client_id | string | yes | 1–500 characters |
| client_auth_method | string | yes | one of "client_secret_basic" | "client_secret_post" | "client_secret_jwt" | "private_key_jwt" | "none" |
| client_secret_env | string | no | at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_private_key_env | string | no | at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_assertion_algorithm | string | no | 1–20 characters |
| client_key_id | string | no | 1–200 characters |
| audience | string | no | 1–500 characters |
| login_scope | string | no | Defaults to "openid profile email". 1–500 characters |
| access_token_algorithms | array of string | no | Omit to accept the default algorithm set. at most 20 items |
{
"slug": "string",
"display_name": "string",
"issuer_url": "string",
"client_id": "string",
"client_auth_method": "client_secret_basic",
"client_secret_env": "string",
"client_private_key_env": "string",
"client_assertion_algorithm": "string",
"client_key_id": "string",
"audience": "string",
"login_scope": "string",
"access_token_algorithms": [
"string"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | at most 100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| display_name | string | no | 1–200 characters |
| issuer_url | string (uri) | no | at most 500 characters |
| client_id | string | no | 1–500 characters |
| enabled | boolean | no | — |
| client_auth_method | string | no | one of "client_secret_basic" | "client_secret_post" | "client_secret_jwt" | "private_key_jwt" | "none" |
| client_secret_env | string | null | no | Omit to keep the current value; null clears it. at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_private_key_env | string | null | no | at most 200 characters · matches ^[A-Z_][A-Z0-9_]*$ |
| client_assertion_algorithm | string | null | no | 1–20 characters |
| client_key_id | string | null | no | 1–200 characters |
| audience | string | null | no | 1–500 characters |
| login_scope | string | no | 1–500 characters |
| access_token_algorithms | array of string | no | at most 20 items |
{
"slug": "string",
"display_name": "string",
"issuer_url": "string",
"client_id": "string",
"enabled": true,
"client_auth_method": "client_secret_basic",
"client_secret_env": "string",
"client_private_key_env": "string",
"client_assertion_algorithm": "string",
"client_key_id": "string",
"audience": "string",
"login_scope": "string",
"access_token_algorithms": [
"string"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| display_name | string | null | yes | Canonical profile, refreshed from ID-token claims at each sign-in. |
| string | null | yes | — | |
| kind | string | yes | one of "human" | "machine" |
| created_at | string (date-time) | yes | — |
| identities | array of UserIdentity | yes | The account's sign-in methods, oldest first. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"display_name": "string",
"email": "string",
"kind": "human",
"created_at": "2026-01-01T00:00:00.000Z",
"identities": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"provider_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"provider_slug": "acme-sso",
"provider_name": "Acme SSO",
"provider_enabled": true,
"subject": "auth0|507f1f77bcf86cd799439011",
"last_authenticated_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| provider_id | string (uuid) | yes | — |
| provider_slug | string | yes | — |
| provider_name | string | yes | — |
| provider_enabled | boolean | yes | A disabled provider's identities remain linked but cannot sign in. |
| subject | string | yes | The `sub` claim this identity presents at its provider. |
| last_authenticated_at | string (date-time) | null | yes | When a sign-in or verified token last presented this identity. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"provider_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"provider_slug": "acme-sso",
"provider_name": "Acme SSO",
"provider_enabled": true,
"subject": "auth0|507f1f77bcf86cd799439011",
"last_authenticated_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| display_name | string | null | yes | — |
| string | null | yes | — | |
| kind | string | yes | one of "human" | "machine" |
| created_at | string (date-time) | yes | — |
| updated_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"display_name": "string",
"email": "string",
"kind": "human",
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| display_name | string | null | no | The name shown for the account everywhere; null clears it. 1–200 characters |
{
"display_name": "Ada Lovelace"
}| Field | Type | Required | Description |
|---|---|---|---|
| provider_id | string | yes | Sign-in provider UUID id or slug. at least 1 character |
| subject | string | yes | The `sub` claim the account's owner presents at that provider. 1–255 characters |
{
"provider_id": "acme-sso",
"subject": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| merged_user_id | string (uuid) | yes | The absorbed (now tombstoned) account. |
| into_user_id | string (uuid) | yes | — |
{
"merged_user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"into_user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| into_user_id | string (uuid) | yes | The surviving account everything is repointed at. |
{
"into_user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of SubjectMatter | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the subject. |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the subject belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a subject owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like forklift-safety. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
{
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "forklift-safety",
"name": "Forklift safety",
"description": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
{
"slug": "string",
"name": "string",
"description": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| record | LearningRecord | yes | — |
| evidence | array of SupportingEvidence | yes | The created evidence rows, in the order they were submitted. |
{
"record": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"learner_user_id": "8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f",
"learner_display_name": "string",
"learner_email": "string",
"title": "string",
"description": "string",
"status": "pending",
"completed_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:00:00.000Z",
"external_id": "string",
"recorded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"approved_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"approved_at": "2026-01-01T00:00:00.000Z",
"rejected_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rejected_at": "2026-01-01T00:00:00.000Z",
"rejection_note": "string",
"subjects": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"archived_at": "2026-01-01T00:00:00.000Z"
}
],
"evidence_count": 0,
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
},
"evidence": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"evidence_type": "score",
"label": "string",
"data": null,
"occurred_at": "2026-01-01T00:00:00.000Z",
"period_start": "2026-01-01T00:00:00.000Z",
"period_end": "2026-01-01T00:00:00.000Z",
"numeric_value": 0,
"numeric_unit": "string",
"boolean_value": true,
"text_value": "string",
"blob_pathname": "string",
"recorded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the record. |
| learner_user_id | string (uuid) | yes | `users.id` of the learner the record is about. |
| learner_display_name | string | null | yes | Snapshot taken at write time; the IdP is not queryable for it later. |
| learner_email | string | null | yes | — |
| title | string | yes | — |
| description | string | null | yes | — |
| status | string | yes | one of "pending" | "rejected" | "in_progress" | "completed" | "expired" | "revoked" |
| completed_at | string (date-time) | null | yes | — |
| expires_at | string (date-time) | null | yes | — |
| external_id | string | null | yes | Importer-assigned id, unique per owning organization. |
| recorded_by | string (uuid) | null | yes | `users.id` of the writer, which is not always the learner. |
| approved_by | string (uuid) | null | yes | `users.id` of the manager who approved the record out of pending; null otherwise. |
| approved_at | string (date-time) | null | yes | — |
| rejected_by | string (uuid) | null | yes | `users.id` of the manager who rejected the record back to its learner; set exactly while the status is rejected. |
| rejected_at | string (date-time) | null | yes | — |
| rejection_note | string | null | yes | The rejecter's note: what to fix before resubmitting. |
| subjects | array of RecordSubjectRef | yes | — |
| evidence_count | integer | yes | — |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"learner_user_id": "8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f",
"learner_display_name": "string",
"learner_email": "string",
"title": "string",
"description": "string",
"status": "pending",
"completed_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:00:00.000Z",
"external_id": "string",
"recorded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"approved_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"approved_at": "2026-01-01T00:00:00.000Z",
"rejected_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rejected_at": "2026-01-01T00:00:00.000Z",
"rejection_note": "string",
"subjects": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"archived_at": "2026-01-01T00:00:00.000Z"
}
],
"evidence_count": 0,
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| archived_at | string (date-time) | null | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"archived_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| learning_record_id | string (uuid) | yes | — |
| evidence_type | string | yes | one of "score" | "date_range" | "quantity" | "url" | "file" | "pass_fail" | "note" |
| label | string | null | yes | — |
| data | EvidencePayload | yes | — |
| occurred_at | string (date-time) | null | yes | — |
| period_start | string (date-time) | null | yes | — |
| period_end | string (date-time) | null | yes | — |
| numeric_value | number | null | yes | — |
| numeric_unit | string | null | yes | — |
| boolean_value | boolean | null | yes | — |
| text_value | string | null | yes | — |
| blob_pathname | string | null | yes | Where the private blob lives, for file evidence. Not itself a URL. |
| recorded_by | string (uuid) | null | yes | `users.id` of the writer. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"evidence_type": "score",
"label": "string",
"data": null,
"occurred_at": "2026-01-01T00:00:00.000Z",
"period_start": "2026-01-01T00:00:00.000Z",
"period_end": "2026-01-01T00:00:00.000Z",
"numeric_value": 0,
"numeric_unit": "string",
"boolean_value": true,
"text_value": "string",
"blob_pathname": "string",
"recorded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}any
null| Field | Type | Required | Description |
|---|---|---|---|
| record | string | yes | JSON-encoded record, in the shape of the CreateLearningRecordRequest schema. Callers without records:write over the owner must use status "pending" and omit learner_user_id (the record is about themselves). at least 1 character |
| evidence | string | yes | JSON-encoded array of evidence, each entry in the shape of the CreateSupportingEvidenceRequest schema (without pathname — a file travels in the file part instead). At most one entry may be of evidence_type file. at least 1 character |
| file | string (binary) | no | The bytes of the one file evidence entry, sent as a file part. Required exactly when the evidence array contains a file entry; the part's size and content type must match what that entry's payload declares. |
{
"record": "{\"owner_organization_id\":\"1b671a64-40d5-491e-99b0-da01ff1f3341\",\"title\":\"Simulator session\",\"status\":\"pending\",\"completed_at\":\"2026-01-01T00:00:00.000Z\",\"subject_matter_ids\":[\"6b3f7a52-8a20-4f0e-9be1-0f6cbd2f2f5c\"]}",
"evidence": "[{\"label\":\"Session length\",\"payload\":{\"evidence_type\":\"quantity\",\"value\":2.5,\"unit\":\"hours\"}}]",
"file": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of LearningRecord | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"learner_user_id": "8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f",
"learner_display_name": "string",
"learner_email": "string",
"title": "string",
"description": "string",
"status": "pending",
"completed_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:00:00.000Z",
"external_id": "string",
"recorded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"approved_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"approved_at": "2026-01-01T00:00:00.000Z",
"rejected_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rejected_at": "2026-01-01T00:00:00.000Z",
"rejection_note": "string",
"subjects": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"archived_at": "2026-01-01T00:00:00.000Z"
}
],
"evidence_count": 0,
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the record belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a record owned by the organization directly. |
| learner_user_id | string (uuid) | no | `users.id` of the learner. Omit to record about yourself — the snapshots below then default to your own profile. |
| learner_display_name | string | no | 1–300 characters |
| learner_email | string | no | 1–300 characters |
| title | string | yes | 1–300 characters |
| description | string | no | at most 5000 characters |
| status | string | no | Callers without records:write over the owner may only create pending records about themselves. rejected cannot be set directly — it is entered through the reject endpoint. one of "pending" | "in_progress" | "completed" | "expired" | "revoked" · defaults to "completed" |
| completed_at | string (date-time) | null | no | — |
| expires_at | string (date-time) | null | no | Must be after completed_at when both are set. |
| external_id | string | no | Importer-assigned id; unique per owning organization, so re-runs are idempotent. 1–300 characters |
| subject_matter_ids | array of string (uuid) | no | Subjects to file the record under; must belong to the same organization. at most 100 items · defaults to [] |
{
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"learner_user_id": "8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f",
"learner_display_name": "string",
"learner_email": "string",
"title": "Forklift operator certification",
"description": "string",
"status": "completed",
"completed_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:00:00.000Z",
"external_id": "string",
"subject_matter_ids": []
}| Field | Type | Required | Description |
|---|---|---|---|
| owner_department_id | string (uuid) | null | no | Move the record to another department of the same organization; null hands it to the organization directly. |
| learner_display_name | string | null | no | 1–300 characters |
| learner_email | string | null | no | 1–300 characters |
| title | string | no | 1–300 characters |
| description | string | null | no | at most 5000 characters |
| status | string | no | rejected cannot be set directly — use the reject endpoint. The learner of a rejected record may set it back to pending, which resubmits it and clears the rejection. one of "pending" | "in_progress" | "completed" | "expired" | "revoked" |
| completed_at | string (date-time) | null | no | — |
| expires_at | string (date-time) | null | no | — |
| external_id | string | null | no | 1–300 characters |
| subject_matter_ids | array of string (uuid) | no | When present, replaces the record's whole subject set. at most 100 items |
{
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"learner_display_name": "string",
"learner_email": "string",
"title": "string",
"description": "string",
"status": "pending",
"completed_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:00:00.000Z",
"external_id": "string",
"subject_matter_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| status | string | no | What the record becomes on approval: completed (default), or in_progress for learning that is still under way. one of "completed" | "in_progress" · defaults to "completed" |
{
"status": "completed"
}| Field | Type | Required | Description |
|---|---|---|---|
| note | string | yes | Why the submission is rejected — what the learner should fix before resubmitting. Shown to the learner on the record. 1–2000 characters |
{
"note": "The simulator hours need the instructor's session log attached."
}| Field | Type | Required | Description |
|---|---|---|---|
| label | string | no | Human label for the measurement, e.g. "Final theory exam". 1–300 characters |
| payload | EvidencePayload | yes | — |
| pathname | string | no | For file evidence only: the pathname issued by the uploads endpoint, after the file has been PUT there. at least 1 character |
{
"label": "string",
"payload": null,
"pathname": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| evidence_id | string (uuid) | yes | Minted for the future evidence row; embedded in the pathname. |
| pathname | string | yes | — |
| upload_url | string | yes | PUT the file bytes here, with the declared content type. |
| expires_at | string (date-time) | yes | — |
{
"evidence_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"pathname": "string",
"upload_url": "string",
"expires_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| content_type | string | yes | one of "application/pdf" | "image/png" | "image/jpeg" | "image/webp" |
| size_bytes | integer | yes | maximum 26214400 |
{
"file_name": "string",
"content_type": "application/pdf",
"size_bytes": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null to clear the label. 1–300 characters |
| payload | any | no | — |
{
"label": "string",
"payload": null
}| Field | Type | Required | Description |
|---|---|---|---|
| url | string | yes | Short-lived presigned GET for the private blob. Not stored, not logged. |
| expires_at | string (date-time) | yes | — |
| file_name | string | yes | — |
| content_type | string | yes | — |
{
"url": "string",
"expires_at": "2026-01-01T00:00:00.000Z",
"file_name": "string",
"content_type": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | yes | — |
| items | array of UserCertificationStatus | yes | — |
{
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"items": [
{
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"awarding_mode": "automatic"
},
"subject_matter": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string"
},
"required_by_roles": [
{
"link_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_name": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_name": "string",
"recert_scheme": "none",
"recert_interval_months": 0,
"recert_anchor_month": 0,
"recert_anchor_day": 0,
"initial_due_at": "2026-01-01T00:00:00.000Z"
}
],
"current_award": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"award_kind": "claimed",
"awarded_at": "2026-01-01T00:00:00.000Z",
"awarded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"revoked_at": "2026-01-01T00:00:00.000Z",
"revoked_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
},
"due_at": "2026-01-01T00:00:00.000Z",
"due_source": "initial",
"overdue": true,
"rules": [
{
"rule_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rule_kind": "subject_hours",
"label": "string",
"current": 0,
"pending_current": 0,
"target": 0,
"evidence_type": "score",
"evidence_present": true,
"pending_evidence_present": true,
"last_sign_off_at": "2026-01-01T00:00:00.000Z",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_slug": "string",
"quiz_name": "string",
"last_quiz_pass_at": "2026-01-01T00:00:00.000Z",
"external_course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_name": "string",
"external_course_code": "string",
"external_server_name": "string",
"last_course_pass_at": "2026-01-01T00:00:00.000Z",
"satisfied": true
}
],
"all_rules_satisfied": true,
"pending_request_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"pending_submission_count": 0
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| certification_type | object | yes | — |
| subject_matter | object | yes | — |
| required_by_roles | array of CertificationRequiredByRole | yes | Every unexpired role grant of the user that requires this type. |
| current_award | CertificationAward | yes | — |
| due_at | string (date-time) | null | yes | When the certification (or the next recertification) falls due; null when no initial due date is set or inherited, and no recert scheme applies. |
| due_source | string | null | yes | initial: the admin-set per-person due date. role: inherited from the earliest role-level initial due date across the requiring roles. recert: computed from the current award and the strictest recert scheme across the requiring roles. one of "initial" | "role" | "recert" | null |
| overdue | boolean | yes | — |
| rules | array of CertificationRuleProgress | yes | Progress per proof rule, evaluated since the current award (or over all history while uncertified). |
| all_rules_satisfied | boolean | yes | — |
| pending_request_id | string (uuid) | null | yes | The user's pending request for this type, if one is open. |
| pending_submission_count | integer | yes | The user's learning-record submissions on the type's subject still awaiting a manager's approval, within the evaluation window — counting only submissions whose approval would advance at least one of the type's rules. The progress they would grant is carried per rule as pending_current / pending_evidence_present. |
{
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"awarding_mode": "automatic"
},
"subject_matter": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string"
},
"required_by_roles": [
{
"link_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_name": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_name": "string",
"recert_scheme": "none",
"recert_interval_months": 0,
"recert_anchor_month": 0,
"recert_anchor_day": 0,
"initial_due_at": "2026-01-01T00:00:00.000Z"
}
],
"current_award": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"award_kind": "claimed",
"awarded_at": "2026-01-01T00:00:00.000Z",
"awarded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"revoked_at": "2026-01-01T00:00:00.000Z",
"revoked_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
},
"due_at": "2026-01-01T00:00:00.000Z",
"due_source": "initial",
"overdue": true,
"rules": [
{
"rule_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rule_kind": "subject_hours",
"label": "string",
"current": 0,
"pending_current": 0,
"target": 0,
"evidence_type": "score",
"evidence_present": true,
"pending_evidence_present": true,
"last_sign_off_at": "2026-01-01T00:00:00.000Z",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_slug": "string",
"quiz_name": "string",
"last_quiz_pass_at": "2026-01-01T00:00:00.000Z",
"external_course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_name": "string",
"external_course_code": "string",
"external_server_name": "string",
"last_course_pass_at": "2026-01-01T00:00:00.000Z",
"satisfied": true
}
],
"all_rules_satisfied": true,
"pending_request_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"pending_submission_count": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| link_id | string (uuid) | yes | — |
| role_id | string (uuid) | yes | — |
| role_name | string | yes | — |
| department_id | string (uuid) | yes | — |
| department_name | string | yes | — |
| organization_id | string (uuid) | yes | — |
| organization_name | string | yes | — |
| recert_scheme | string | yes | one of "none" | "anniversary" | "fixed_calendar" |
| recert_interval_months | number | null | yes | — |
| recert_anchor_month | number | null | yes | — |
| recert_anchor_day | number | null | yes | — |
| initial_due_at | string (date-time) | null | yes | The link's role-level initial due date; null when the role sets no deadline. |
{
"link_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_name": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_name": "string",
"recert_scheme": "none",
"recert_interval_months": 0,
"recert_anchor_month": 0,
"recert_anchor_day": 0,
"initial_due_at": "2026-01-01T00:00:00.000Z"
}The latest non-revoked award, or null while the user is uncertified.
| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | — |
| certification_type_id | string (uuid) | yes | — |
| award_kind | string | yes | one of "claimed" | "approved" | "granted" |
| awarded_at | string (date-time) | yes | — |
| awarded_by | string (uuid) | null | yes | Null on a self-claimed award. |
| revoked_at | string (date-time) | null | yes | — |
| revoked_by | string (uuid) | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"award_kind": "claimed",
"awarded_at": "2026-01-01T00:00:00.000Z",
"awarded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"revoked_at": "2026-01-01T00:00:00.000Z",
"revoked_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| rule_id | string (uuid) | yes | — |
| rule_kind | string | yes | one of "subject_hours" | "subject_record_count" | "evidence_document" | "manual_sign_off" | "quiz_pass" | "external_course_pass" |
| label | string | null | yes | — |
| current | number | null | yes | Hours or completed records accumulated in the evaluation window, for the threshold rule kinds; null for the others. |
| pending_current | number | null | yes | Hours or records the user's still-pending submissions in the window would add once a manager approves them, for the threshold rule kinds; null for the others. |
| target | number | null | yes | The rule's threshold, for the threshold rule kinds; null for the others. |
| evidence_type | string | null | yes | The evidence type an evidence_document rule looks for. one of "score" | "date_range" | "quantity" | "url" | "file" | "pass_fail" | "note" | null |
| evidence_present | boolean | null | yes | Whether a completed record in the window carries that evidence; null for other kinds. |
| pending_evidence_present | boolean | null | yes | Whether a still-pending submission in the window carries that evidence; null for other kinds. |
| last_sign_off_at | string (date-time) | null | yes | Most recent sign-off for a manual_sign_off rule, window regardless. |
| quiz_id | string (uuid) | null | yes | The quiz a quiz_pass rule requires; null for other kinds. |
| quiz_slug | string | null | yes | — |
| quiz_name | string | null | yes | — |
| last_quiz_pass_at | string (date-time) | null | yes | Most recent passing attempt for a quiz_pass rule, window regardless. |
| external_course_id | string (uuid) | null | yes | Catalogue id of the course an external_course_pass rule requires; null for other kinds. |
| external_course_name | string | null | yes | — |
| external_course_code | string | null | yes | The LMS's own identifier of that course. |
| external_server_name | string | null | yes | — |
| last_course_pass_at | string (date-time) | null | yes | Most recent passing grade for an external_course_pass rule, window regardless. |
| satisfied | boolean | yes | — |
{
"rule_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rule_kind": "subject_hours",
"label": "string",
"current": 0,
"pending_current": 0,
"target": 0,
"evidence_type": "score",
"evidence_present": true,
"pending_evidence_present": true,
"last_sign_off_at": "2026-01-01T00:00:00.000Z",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_slug": "string",
"quiz_name": "string",
"last_quiz_pass_at": "2026-01-01T00:00:00.000Z",
"external_course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_name": "string",
"external_course_code": "string",
"external_server_name": "string",
"last_course_pass_at": "2026-01-01T00:00:00.000Z",
"satisfied": true
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | — |
| user_display_name | string | null | yes | The requester's profile name, when their account carries one. |
| user_email | string | null | yes | — |
| certification_type_id | string (uuid) | yes | — |
| certification_type | object | yes | The requested certification type, for display. |
| status | string | yes | one of "pending" | "approved" | "rejected" | "withdrawn" |
| message | string | null | yes | — |
| decided_by | string (uuid) | null | yes | — |
| decided_at | string (date-time) | null | yes | — |
| decision_note | string | null | yes | — |
| resulting_certification_id | string (uuid) | null | yes | The award an approval produced; set exactly when status is approved. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"user_email": "string",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"awarding_mode": "automatic"
},
"status": "pending",
"message": "string",
"decided_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"decided_at": "2026-01-01T00:00:00.000Z",
"decision_note": "string",
"resulting_certification_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| message | string | no | Optional note to whoever decides the request. at most 2000 characters |
{
"message": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| decision_note | string | no | Optional note recorded with the decision, shown to the requester. at most 2000 characters |
{
"decision_note": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of CertificationRequest | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"user_email": "string",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"awarding_mode": "automatic"
},
"status": "pending",
"message": "string",
"decided_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"decided_at": "2026-01-01T00:00:00.000Z",
"decision_note": "string",
"resulting_certification_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | yes | `users.id` of the user the certification is granted to. |
| awarded_at | string (date-time) | no | Omit for now; set to backdate a grant, e.g. one imported from paper. |
{
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"awarded_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of CertificationAward | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"award_kind": "claimed",
"awarded_at": "2026-01-01T00:00:00.000Z",
"awarded_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"revoked_at": "2026-01-01T00:00:00.000Z",
"revoked_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of CertificationDueDate | yes | — |
{
"items": [
{
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"initial_due_at": "2026-01-01T00:00:00.000Z",
"set_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | — |
| certification_type_id | string (uuid) | yes | — |
| initial_due_at | string (date-time) | yes | When the user's *initial* certification falls due; recerts are computed. Overrides any role-level initial due date. |
| set_by | string (uuid) | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"initial_due_at": "2026-01-01T00:00:00.000Z",
"set_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| initial_due_at | string (date-time) | yes | When this user's initial certification falls due. Takes precedence over any role-level initial due date, earlier or later alike. |
{
"initial_due_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| requirement_id | string (uuid) | yes | — |
| certification_type_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | — |
| signed_off_by | string (uuid) | yes | — |
| signed_off_at | string (date-time) | yes | — |
| note | string | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"requirement_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"signed_off_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"signed_off_at": "2026-01-01T00:00:00.000Z",
"note": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | yes | `users.id` of the user being signed off. |
| signed_off_at | string (date-time) | no | Omit for now; set to backdate a sign-off. |
| note | string | no | at most 2000 characters |
{
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"signed_off_at": "2026-01-01T00:00:00.000Z",
"note": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of CertificationSignOff | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"requirement_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"signed_off_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"signed_off_at": "2026-01-01T00:00:00.000Z",
"note": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of CertificationType | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"description_markdown": "string",
"awarding_mode": "automatic",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the certification type. |
| subject_matter_id | string (uuid) | yes | The subject matter whose records and evidence satisfy the rules. |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| description_markdown | string | null | yes | Long-form narrative markdown below the summary line; never parsed. |
| awarding_mode | string | yes | automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides. one of "automatic" | "approval" |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"description_markdown": "string",
"awarding_mode": "automatic",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the certification type belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a type owned by the organization directly. |
| subject_matter_id | string (uuid) | yes | A subject matter of the same organization; immutable once created. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like forklift-operator. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| description_markdown | string | no | Omit or send empty for no long-form markdown description. at most 50000 characters |
| awarding_mode | string | yes | automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides. one of "automatic" | "approval" |
{
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "forklift-operator",
"name": "Certified forklift operator",
"description": "string",
"description_markdown": "string",
"awarding_mode": "automatic"
}| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | Where the private blob will live; pass it back when creating the attachment. Not itself a URL. |
| upload_url | string | yes | PUT the file bytes here, with the declared content type. |
| expires_at | string (date-time) | yes | — |
{
"pathname": "string",
"upload_url": "string",
"expires_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| content_type | string | yes | The file's content type; any type is allowed, but the upload pins this one. 1–200 characters |
| size_bytes | integer | yes | maximum 524288000 |
{
"file_name": "string",
"content_type": "string",
"size_bytes": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| file_name | string | yes | The uploaded file's original name, kept verbatim for downloads. |
| content_type | string | yes | The content type the store recorded at upload; any type is allowed. |
| label | string | null | yes | Admin-written label shown in place of the file name, or null. |
| position | integer | yes | Authored order the attachments render in. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | The pathname an upload ticket was issued for, after PUTting the file. at least 1 character |
| file_name | string | yes | 1–300 characters |
| label | string | no | Omit or send empty to show the file name instead. at most 200 characters |
{
"pathname": "string",
"file_name": "string",
"label": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null or empty to clear the label back to the file name. at most 200 characters |
| position | integer | no | New slot in the authored order. minimum 0 |
{
"label": "string",
"position": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the certification type. |
| subject_matter_id | string (uuid) | yes | The subject matter whose records and evidence satisfy the rules. |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| description_markdown | string | null | yes | Long-form narrative markdown below the summary line; never parsed. |
| awarding_mode | string | yes | automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides. one of "automatic" | "approval" |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
| rules | array of CertificationRule | yes | The proof rules of this type; all of them must be satisfied at once. |
| attachments | array of DescriptionAttachment | yes | The type's description attachments, in authored order — e.g. a blank form to fill and submit as evidence. The bytes are fetched through GET /certifications/{cert_id}/attachments/{attachment_id}/download. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"description_markdown": "string",
"awarding_mode": "automatic",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z",
"rules": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rule_kind": "subject_hours",
"label": "string",
"threshold_hours": 0,
"threshold_count": 0,
"evidence_type": "score",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| certification_type_id | string (uuid) | yes | — |
| rule_kind | string | yes | subject_hours: evidenced hours on the type's subject must reach threshold_hours. subject_record_count: completed records on the subject must reach threshold_count. evidence_document: a completed record on the subject must carry evidence of evidence_type. manual_sign_off: a permission holder must record a sign-off for the rule. quiz_pass: the user must have a passing attempt of the quiz named by quiz_id. external_course_pass: the user must have a passing grade of the external LMS course named by external_course_id. one of "subject_hours" | "subject_record_count" | "evidence_document" | "manual_sign_off" | "quiz_pass" | "external_course_pass" |
| label | string | null | yes | — |
| threshold_hours | number | null | yes | — |
| threshold_count | number | null | yes | — |
| evidence_type | string | null | yes | one of "score" | "date_range" | "quantity" | "url" | "file" | "pass_fail" | "note" | null |
| quiz_id | string (uuid) | null | yes | — |
| external_course_id | string (uuid) | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"rule_kind": "subject_hours",
"label": "string",
"threshold_hours": 0,
"threshold_count": 0,
"evidence_type": "score",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| description_markdown | string | null | no | Send null to clear the long-form markdown description. at most 50000 characters |
| awarding_mode | string | no | Changing the mode does not touch existing requests: pending requests on a now-automatic type remain decidable and withdrawable. one of "automatic" | "approval" |
{
"slug": "string",
"name": "string",
"description": "string",
"description_markdown": "string",
"awarding_mode": "automatic"
}| Field | Type | Required | Description |
|---|---|---|---|
| rule_kind | string | yes | subject_hours: evidenced hours on the type's subject must reach threshold_hours. subject_record_count: completed records on the subject must reach threshold_count. evidence_document: a completed record on the subject must carry evidence of evidence_type. manual_sign_off: a permission holder must record a sign-off for the rule. quiz_pass: the user must have a passing attempt of the quiz named by quiz_id. external_course_pass: the user must have a passing grade of the external LMS course named by external_course_id. one of "subject_hours" | "subject_record_count" | "evidence_document" | "manual_sign_off" | "quiz_pass" | "external_course_pass" |
| label | string | no | Human label shown wherever the rule's progress is, e.g. "20 simulator hours". 1–200 characters |
| threshold_hours | number | no | Hours required, for subject_hours rules. Counted from quantity evidence in hours or minutes, plus the elapsed time of date_range evidence. |
| threshold_count | integer | no | Completed records required, for subject_record_count rules. |
| evidence_type | string | no | Evidence type that must be present, for evidence_document rules. one of "score" | "date_range" | "quantity" | "url" | "file" | "pass_fail" | "note" |
| quiz_id | string (uuid) | no | Quiz that must be passed, for quiz_pass rules. A quiz of the type's own organization. |
| external_course_id | string (uuid) | no | Catalogue id of the external LMS course that must be passed, for external_course_pass rules. A course of the type's own organization. Passing is decided by the course: its passing_score when set, the imported passed flag otherwise. |
{
"rule_kind": "subject_hours",
"label": "string",
"threshold_hours": 20,
"threshold_count": 3,
"evidence_type": "score",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null to clear the label. 1–200 characters |
| threshold_hours | number | no | Hours required, for subject_hours rules. Counted from quantity evidence in hours or minutes, plus the elapsed time of date_range evidence. |
| threshold_count | integer | no | Completed records required, for subject_record_count rules. |
| evidence_type | string | no | Evidence type that must be present, for evidence_document rules. one of "score" | "date_range" | "quantity" | "url" | "file" | "pass_fail" | "note" |
| quiz_id | string (uuid) | no | Quiz that must be passed, for quiz_pass rules. A quiz of the type's own organization. |
| external_course_id | string (uuid) | no | Catalogue id of the external LMS course that must be passed, for external_course_pass rules. A course of the type's own organization. Passing is decided by the course: its passing_score when set, the imported passed flag otherwise. |
{
"label": "string",
"threshold_hours": 20,
"threshold_count": 3,
"evidence_type": "score",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of RoleRequiredCertification | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"recert_scheme": "none",
"recert_interval_months": 0,
"recert_anchor_month": 0,
"recert_anchor_day": 0,
"initial_due_at": "2026-01-01T00:00:00.000Z",
"created_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z",
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"awarding_mode": "automatic",
"archived_at": "2026-01-01T00:00:00.000Z"
}
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| role_id | string (uuid) | yes | — |
| department_id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| certification_type_id | string (uuid) | yes | — |
| recert_scheme | string | yes | none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year. one of "none" | "anniversary" | "fixed_calendar" |
| recert_interval_months | number | null | yes | — |
| recert_anchor_month | number | null | yes | — |
| recert_anchor_day | number | null | yes | — |
| initial_due_at | string (date-time) | null | yes | When holders' *initial* certification falls due; null when the role sets no deadline. Per-person due dates override it. |
| created_by | string (uuid) | null | yes | — |
| created_at | string (date-time) | yes | — |
| certification_type | object | yes | The linked certification type, for display. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"recert_scheme": "none",
"recert_interval_months": 0,
"recert_anchor_month": 0,
"recert_anchor_day": 0,
"initial_due_at": "2026-01-01T00:00:00.000Z",
"created_by": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z",
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"awarding_mode": "automatic",
"archived_at": "2026-01-01T00:00:00.000Z"
}
}| Field | Type | Required | Description |
|---|---|---|---|
| certification_type_id | string (uuid) | yes | A certification type of the same organization as the role's department. |
| recert_scheme | string | yes | none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year. one of "none" | "anniversary" | "fixed_calendar" |
| recert_interval_months | integer | no | Months between recertifications, for the anniversary scheme. |
| recert_anchor_month | integer | no | Calendar month (1-12) the certification falls due, for fixed_calendar. minimum 1 · maximum 12 |
| recert_anchor_day | integer | no | Day of the anchor month (1-31, clamped to the month's length), for fixed_calendar. minimum 1 · maximum 31 |
| initial_due_at | string (date-time) | no | When the *initial* certification falls due for every holder of the role. A member's per-person due date overrides it; omit to set no role-level deadline. |
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"recert_scheme": "none",
"recert_interval_months": 12,
"recert_anchor_month": 3,
"recert_anchor_day": 31,
"initial_due_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| recert_scheme | string | yes | none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year. one of "none" | "anniversary" | "fixed_calendar" |
| recert_interval_months | integer | no | Months between recertifications, for the anniversary scheme. |
| recert_anchor_month | integer | no | Calendar month (1-12) the certification falls due, for fixed_calendar. minimum 1 · maximum 12 |
| recert_anchor_day | integer | no | Day of the anchor month (1-31, clamped to the month's length), for fixed_calendar. minimum 1 · maximum 31 |
| initial_due_at | string (date-time) | no | When the *initial* certification falls due for every holder of the role. A member's per-person due date overrides it; omit to set no role-level deadline. |
{
"recert_scheme": "none",
"recert_interval_months": 12,
"recert_anchor_month": 3,
"recert_anchor_day": 31,
"initial_due_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| role | TeamRoleRef | yes | — |
| trainee_count | integer | yes | Holders of the role whose grant has not expired. |
| fully_complete_count | integer | yes | Trainees whose every required type is complete. |
| fully_complete_percent | number | null | yes | fully_complete_count over trainee_count, 0–100; null with no trainees. |
| certifications | array of RoleRequiredCertificationStat | yes | One tally per required certification type, archived types left out. Empty when the role requires nothing — every trainee is then vacuously complete. |
| trainees | array of TraineeCompletion | yes | The roster, named members first, each with their per-type standing. |
{
"role": {
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_slug": "string",
"organization_name": "string"
},
"trainee_count": 0,
"fully_complete_count": 0,
"fully_complete_percent": 0,
"certifications": [
{
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"awarding_mode": "automatic"
},
"complete_count": 0,
"overdue_count": 0,
"not_certified_count": 0,
"complete_percent": 0
}
],
"trainees": [
{
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"display_name": "string",
"email": "string",
"fully_complete": true,
"requirements": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"state": "complete",
"due_at": "2026-01-01T00:00:00.000Z",
"awarded_at": "2026-01-01T00:00:00.000Z"
}
]
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| role_id | string (uuid) | yes | — |
| role_slug | string | yes | — |
| role_name | string | yes | — |
| department_id | string (uuid) | yes | — |
| department_slug | string | yes | — |
| department_name | string | yes | — |
| organization_id | string (uuid) | yes | — |
| organization_slug | string | yes | — |
| organization_name | string | yes | — |
{
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_slug": "string",
"organization_name": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| certification_type | object | yes | — |
| complete_count | integer | yes | — |
| overdue_count | integer | yes | — |
| not_certified_count | integer | yes | — |
| complete_percent | number | null | yes | complete_count over the trainee count, 0–100; null with no trainees. |
{
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"awarding_mode": "automatic"
},
"complete_count": 0,
"overdue_count": 0,
"not_certified_count": 0,
"complete_percent": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| user_id | string (uuid) | yes | — |
| display_name | string | null | yes | The trainee's profile name from users; null when unset. |
| string | null | yes | — | |
| fully_complete | boolean | yes | Every required type of the role is complete; vacuously true with none. |
| requirements | array of TraineeRequirementStatus | yes | In the same order as the completion's certifications. |
{
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"display_name": "string",
"email": "string",
"fully_complete": true,
"requirements": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"state": "complete",
"due_at": "2026-01-01T00:00:00.000Z",
"awarded_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| certification_type_id | string (uuid) | yes | — |
| state | string | yes | complete: current award and not past due. overdue: past due, lapsed or never certified. not_certified: no award, no due date passed. one of "complete" | "overdue" | "not_certified" |
| due_at | string (date-time) | null | yes | When the certification (or next recertification) falls due, if a date applies. |
| awarded_at | string (date-time) | null | yes | When the current award was made; null while uncertified. |
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"state": "complete",
"due_at": "2026-01-01T00:00:00.000Z",
"awarded_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of RoleCertificationCompletion | yes | Every role in the caller's managed subtree that requires at least one live certification type, fully tallied. |
{
"items": [
{
"role": {
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_slug": "string",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_slug": "string",
"department_name": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_slug": "string",
"organization_name": "string"
},
"trainee_count": 0,
"fully_complete_count": 0,
"fully_complete_percent": 0,
"certifications": [
{
"certification_type": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"awarding_mode": "automatic"
},
"complete_count": 0,
"overdue_count": 0,
"not_certified_count": 0,
"complete_percent": 0
}
],
"trainees": [
{
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"display_name": "string",
"email": "string",
"fully_complete": true,
"requirements": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"state": "complete",
"due_at": "2026-01-01T00:00:00.000Z",
"awarded_at": "2026-01-01T00:00:00.000Z"
}
]
}
]
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of QuizQuestion | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"question_bank_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"kind": "true_false",
"prompt": "string",
"prompt_format": "plaintext",
"prompt_pdf_file_name": "string",
"explanation": "string",
"correct_boolean": true,
"correct_number": 0,
"tolerance": 0,
"position": 0,
"options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"is_correct": true,
"match_label": "string",
"position": 0
}
],
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| question_bank_id | string (uuid) | yes | — |
| kind | string | yes | true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. one of "true_false" | "multiple_choice" | "multi_select" | "ordering" | "short_answer" | "numeric" | "matching" |
| prompt | string | yes | — |
| prompt_format | string | yes | plaintext: the prompt renders verbatim. markdown: the same prompt text renders as Markdown. pdf: an uploaded PDF carries the question; the prompt text is the instruction shown above it. one of "plaintext" | "markdown" | "pdf" |
| prompt_pdf_file_name | string | null | yes | Original name of the attached PDF, for pdf prompts; null otherwise. The file itself is fetched through the question's pdf endpoint. |
| explanation | string | null | yes | Shown in answer review when the quiz reveals correct answers. |
| correct_boolean | boolean | null | yes | The answer of a true_false question; null for every other kind. |
| correct_number | number | null | yes | The answer of a numeric question; null for every other kind. |
| tolerance | number | null | yes | How far a numeric response may stray and still count; null otherwise. |
| position | integer | yes | — |
| options | array of QuizQuestionOption | yes | Choices, ordering items (authored order = correct order), accepted short answers or matching pairs, in authored order; empty for true_false and numeric questions. |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"question_bank_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"kind": "true_false",
"prompt": "string",
"prompt_format": "plaintext",
"prompt_pdf_file_name": "string",
"explanation": "string",
"correct_boolean": true,
"correct_number": 0,
"tolerance": 0,
"position": 0,
"options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"is_correct": true,
"match_label": "string",
"position": 0
}
],
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| label | string | yes | — |
| is_correct | boolean | yes | Meaningful on choice questions; always true on accepted short answers and false on ordering items and matching pairs. |
| match_label | string | null | yes | The right-hand side of a matching pair; null on every other kind. |
| position | integer | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"is_correct": true,
"match_label": "string",
"position": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| kind | string | yes | true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. one of "true_false" | "multiple_choice" | "multi_select" | "ordering" | "short_answer" | "numeric" | "matching" |
| prompt | string | yes | 1–2000 characters |
| prompt_format | string | no | Defaults to plaintext. one of "plaintext" | "markdown" | "pdf" |
| prompt_pdf | QuestionPromptPdf | no | — |
| explanation | string | no | Omit or send empty for no explanation. at most 2000 characters |
| correct_boolean | boolean | no | Required for true_false questions; omit for every other kind. |
| correct_number | number | no | numeric questions only. |
| tolerance | number | no | numeric questions only; 0 demands the exact value. minimum 0 |
| options | array of object | no | Up to 10 options in presentation order — at least two, or one accepted answer for short_answer. multiple_choice needs exactly one correct option; multi_select at least one; ordering lists items in their correct order; matching gives each option its match_label. 1–10 items |
{
"kind": "true_false",
"prompt": "Fire doors may be propped open during deliveries.",
"prompt_format": "plaintext",
"prompt_pdf": {
"pathname": "string",
"file_name": "string"
},
"explanation": "string",
"correct_boolean": true,
"correct_number": 42,
"tolerance": 0.5,
"options": [
{
"label": "string",
"is_correct": true,
"match_label": "string"
}
]
}Required for pdf prompts; omit for plaintext and markdown.
| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | The pathname issued by the bank's question-pdfs endpoint, after the PUT. at least 1 character |
| file_name | string | yes | 1–300 characters |
{
"pathname": "string",
"file_name": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | Where the private blob will live; pass it as prompt_pdf.pathname when creating or updating the question. Not itself a URL. |
| upload_url | string | yes | PUT the file bytes here, with content type application/pdf. |
| expires_at | string (date-time) | yes | — |
{
"pathname": "string",
"upload_url": "string",
"expires_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | 1–300 characters |
| size_bytes | integer | yes | maximum 26214400 |
{
"file_name": "string",
"size_bytes": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of QuizQuestionBank | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"question_count": 0,
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the bank. |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| question_count | integer | yes | Unarchived questions currently in the bank. |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"question_count": 0,
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the bank belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a bank owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like fire-safety-basics. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
{
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "fire-safety-basics",
"name": "Fire safety basics",
"description": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
{
"slug": "string",
"name": "string",
"description": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| prompt | string | no | 1–2000 characters |
| prompt_format | string | no | Switching between plaintext and markdown re-renders the same prompt text. Switching to pdf needs prompt_pdf (unless one is already attached); switching away discards the attachment. one of "plaintext" | "markdown" | "pdf" |
| prompt_pdf | any | no | — |
| explanation | string | null | no | Send null to clear the explanation. at most 2000 characters |
| correct_boolean | boolean | no | For true_false questions only. |
| correct_number | number | no | numeric questions only. |
| tolerance | number | no | numeric questions only; 0 demands the exact value. minimum 0 |
| options | array of object | no | For questions with options only. Replaces the whole option set; answer review of attempts that saw the old options degrades accordingly. Grading of submitted attempts never changes. 1–10 items |
{
"prompt": "string",
"prompt_format": "plaintext",
"prompt_pdf": null,
"explanation": "string",
"correct_boolean": true,
"correct_number": 42,
"tolerance": 0.5,
"options": [
{
"label": "string",
"is_correct": true,
"match_label": "string"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of AssignedQuiz | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_name": "string",
"delivery_kind": "live",
"time_limit_seconds": 0,
"pass_percentage": 0,
"max_attempts": 0,
"show_correct_answers": true,
"current_sitting": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"opens_at": "2026-01-01T00:00:00.000Z",
"closes_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
},
"next_sitting": null,
"state": "upcoming",
"attempts_used": 0,
"in_progress_attempt_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"latest_attempt": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scheduled_quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"attempt_number": 0,
"status": "in_progress",
"started_at": "2026-01-01T00:00:00.000Z",
"deadline_at": "2026-01-01T00:00:00.000Z",
"submitted_at": "2026-01-01T00:00:00.000Z",
"auto_submitted": true,
"question_count": 0,
"correct_count": 0,
"score_percentage": 0,
"passed": true,
"results_visible": true
},
"has_passed": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| organization_id | string (uuid) | yes | — |
| organization_name | string | yes | — |
| delivery_kind | string | yes | live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply. one of "live" | "async" |
| time_limit_seconds | integer | null | yes | — |
| pass_percentage | number | yes | — |
| max_attempts | integer | null | yes | — |
| show_correct_answers | boolean | yes | — |
| current_sitting | ScheduledQuiz | yes | — |
| next_sitting | any | yes | — |
| state | string | yes | Takeability right now: async published quizzes are open; live ones are open inside a sitting, upcoming while one is yet to open, closed otherwise. one of "upcoming" | "open" | "closed" |
| attempts_used | integer | yes | Attempts counted against max_attempts: within the current sitting for live quizzes (0 when none is open), lifetime for async ones. |
| in_progress_attempt_id | string (uuid) | null | yes | — |
| latest_attempt | QuizAttempt | yes | — |
| has_passed | boolean | null | yes | Whether any attempt passed; null while no attempt has been submitted or results are withheld. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_name": "string",
"delivery_kind": "live",
"time_limit_seconds": 0,
"pass_percentage": 0,
"max_attempts": 0,
"show_correct_answers": true,
"current_sitting": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"opens_at": "2026-01-01T00:00:00.000Z",
"closes_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
},
"next_sitting": null,
"state": "upcoming",
"attempts_used": 0,
"in_progress_attempt_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"latest_attempt": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scheduled_quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"attempt_number": 0,
"status": "in_progress",
"started_at": "2026-01-01T00:00:00.000Z",
"deadline_at": "2026-01-01T00:00:00.000Z",
"submitted_at": "2026-01-01T00:00:00.000Z",
"auto_submitted": true,
"question_count": 0,
"correct_count": 0,
"score_percentage": 0,
"passed": true,
"results_visible": true
},
"has_passed": true
}The sitting open right now, for live quizzes; null otherwise.
| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| quiz_id | string (uuid) | yes | — |
| label | string | null | yes | Human label for the sitting, e.g. "2026 annual recertification". |
| opens_at | string (date-time) | yes | — |
| closes_at | string (date-time) | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"opens_at": "2026-01-01T00:00:00.000Z",
"closes_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| quiz_id | string (uuid) | yes | — |
| scheduled_quiz_id | string (uuid) | null | yes | The sitting a live attempt was sat in; null on async attempts. |
| user_id | string (uuid) | yes | — |
| user_display_name | string | null | yes | Display name of the taker's user account, when the read joins it; else null. |
| attempt_number | integer | yes | — |
| status | string | yes | one of "in_progress" | "submitted" |
| started_at | string (date-time) | yes | — |
| deadline_at | string (date-time) | null | yes | When the attempt auto-submits as-is: started_at plus the time limit, capped by the sitting's close. Null when neither applies. |
| submitted_at | string (date-time) | null | yes | — |
| auto_submitted | boolean | yes | True when the deadline finalized the attempt rather than the taker. |
| question_count | integer | yes | — |
| correct_count | integer | null | yes | — |
| score_percentage | number | null | yes | — |
| passed | boolean | null | yes | — |
| results_visible | boolean | yes | Whether the caller may see this attempt's results. False nulls the three fields above. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scheduled_quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"attempt_number": 0,
"status": "in_progress",
"started_at": "2026-01-01T00:00:00.000Z",
"deadline_at": "2026-01-01T00:00:00.000Z",
"submitted_at": "2026-01-01T00:00:00.000Z",
"auto_submitted": true,
"question_count": 0,
"correct_count": 0,
"score_percentage": 0,
"passed": true,
"results_visible": true
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| quiz_id | string (uuid) | yes | — |
| scheduled_quiz_id | string (uuid) | null | yes | The sitting a live attempt was sat in; null on async attempts. |
| user_id | string (uuid) | yes | — |
| user_display_name | string | null | yes | Display name of the taker's user account, when the read joins it; else null. |
| attempt_number | integer | yes | — |
| status | string | yes | one of "in_progress" | "submitted" |
| started_at | string (date-time) | yes | — |
| deadline_at | string (date-time) | null | yes | When the attempt auto-submits as-is: started_at plus the time limit, capped by the sitting's close. Null when neither applies. |
| submitted_at | string (date-time) | null | yes | — |
| auto_submitted | boolean | yes | True when the deadline finalized the attempt rather than the taker. |
| question_count | integer | yes | — |
| correct_count | integer | null | yes | — |
| score_percentage | number | null | yes | — |
| passed | boolean | null | yes | — |
| results_visible | boolean | yes | Whether the caller may see this attempt's results. False nulls the three fields above. |
| questions | array of TakerQuestion | yes | The attempt's paper in presentation order, answers stripped. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scheduled_quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"attempt_number": 0,
"status": "in_progress",
"started_at": "2026-01-01T00:00:00.000Z",
"deadline_at": "2026-01-01T00:00:00.000Z",
"submitted_at": "2026-01-01T00:00:00.000Z",
"auto_submitted": true,
"question_count": 0,
"correct_count": 0,
"score_percentage": 0,
"passed": true,
"results_visible": true,
"questions": [
{
"question_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"position": 0,
"kind": "true_false",
"prompt": "string",
"prompt_format": "plaintext",
"prompt_pdf_file_name": "string",
"options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string"
}
],
"match_options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string"
}
],
"response_boolean": true,
"selected_option_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"response_text": "string",
"response_number": 0,
"response_matches": [
{
"option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"match_option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}
],
"answered_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| question_id | string (uuid) | yes | — |
| position | integer | yes | — |
| kind | string | yes | true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. one of "true_false" | "multiple_choice" | "multi_select" | "ordering" | "short_answer" | "numeric" | "matching" |
| prompt | string | yes | — |
| prompt_format | string | yes | plaintext: the prompt renders verbatim. markdown: the same prompt text renders as Markdown. pdf: an uploaded PDF carries the question; the prompt text is the instruction shown above it. one of "plaintext" | "markdown" | "pdf" |
| prompt_pdf_file_name | string | null | yes | Original name of the attached PDF, for pdf prompts; null otherwise. Fetch the file through the attempt's question pdf endpoint. |
| options | array of TakerQuestionOption | yes | Choices, ordering items or the left column of a matching question, in the order this attempt presents them — correctness stripped. Empty for true_false, short_answer and numeric questions. |
| match_options | array of TakerQuestionOption | yes | The right column of a matching question in its own shuffled order, each entry identified by the option whose match_label it is. Empty for every other kind. |
| response_boolean | boolean | null | yes | — |
| selected_option_ids | array of string (uuid) | null | yes | Chosen options of a choice question, or every item of an ordering question in the taker's arrangement. |
| response_text | string | null | yes | A short_answer response. |
| response_number | number | null | yes | A numeric response. |
| response_matches | array of ResponseMatch | null | yes | The pairs made so far on a matching question. |
| answered_at | string (date-time) | null | yes | — |
{
"question_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"position": 0,
"kind": "true_false",
"prompt": "string",
"prompt_format": "plaintext",
"prompt_pdf_file_name": "string",
"options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string"
}
],
"match_options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string"
}
],
"response_boolean": true,
"selected_option_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"response_text": "string",
"response_number": 0,
"response_matches": [
{
"option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"match_option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}
],
"answered_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| label | string | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| option_id | string (uuid) | yes | An entry of the question's options. |
| match_option_id | string (uuid) | yes | An entry of the question's match_options — the right-hand label chosen. |
{
"option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"match_option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| quiz_id | string (uuid) | yes | — |
| scheduled_quiz_id | string (uuid) | null | yes | The sitting a live attempt was sat in; null on async attempts. |
| user_id | string (uuid) | yes | — |
| user_display_name | string | null | yes | Display name of the taker's user account, when the read joins it; else null. |
| attempt_number | integer | yes | — |
| status | string | yes | one of "in_progress" | "submitted" |
| started_at | string (date-time) | yes | — |
| deadline_at | string (date-time) | null | yes | When the attempt auto-submits as-is: started_at plus the time limit, capped by the sitting's close. Null when neither applies. |
| submitted_at | string (date-time) | null | yes | — |
| auto_submitted | boolean | yes | True when the deadline finalized the attempt rather than the taker. |
| question_count | integer | yes | — |
| correct_count | integer | null | yes | — |
| score_percentage | number | null | yes | — |
| passed | boolean | null | yes | — |
| results_visible | boolean | yes | Whether the caller may see this attempt's results. False nulls the three fields above. |
| questions | array of QuizReviewQuestion | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scheduled_quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"attempt_number": 0,
"status": "in_progress",
"started_at": "2026-01-01T00:00:00.000Z",
"deadline_at": "2026-01-01T00:00:00.000Z",
"submitted_at": "2026-01-01T00:00:00.000Z",
"auto_submitted": true,
"question_count": 0,
"correct_count": 0,
"score_percentage": 0,
"passed": true,
"results_visible": true,
"questions": [
{
"question_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"position": 0,
"kind": "true_false",
"prompt": "string",
"prompt_format": "plaintext",
"prompt_pdf_file_name": "string",
"explanation": "string",
"correct_boolean": true,
"correct_number": 0,
"tolerance": 0,
"options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"is_correct": true,
"match_label": "string"
}
],
"response_boolean": true,
"selected_option_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"response_text": "string",
"response_number": 0,
"response_matches": [
{
"option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"match_option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}
],
"is_correct": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| question_id | string (uuid) | yes | — |
| position | integer | yes | — |
| kind | string | yes | true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. one of "true_false" | "multiple_choice" | "multi_select" | "ordering" | "short_answer" | "numeric" | "matching" |
| prompt | string | yes | — |
| prompt_format | string | yes | plaintext: the prompt renders verbatim. markdown: the same prompt text renders as Markdown. pdf: an uploaded PDF carries the question; the prompt text is the instruction shown above it. one of "plaintext" | "markdown" | "pdf" |
| prompt_pdf_file_name | string | null | yes | — |
| explanation | string | null | yes | — |
| correct_boolean | boolean | null | yes | — |
| correct_number | number | null | yes | — |
| tolerance | number | null | yes | — |
| options | array of ReviewQuestionOption | yes | In presentation order for choice and matching questions; in *authored* (correct) order for ordering questions; the accepted answers for short_answer. |
| response_boolean | boolean | null | yes | — |
| selected_option_ids | array of string (uuid) | null | yes | Chosen options of a choice question, or every item of an ordering question in the taker's arrangement. |
| response_text | string | null | yes | A short_answer response. |
| response_number | number | null | yes | A numeric response. |
| response_matches | array of ResponseMatch | null | yes | The pairs made so far on a matching question. |
| is_correct | boolean | null | yes | As graded at submit time; unanswered questions were graded incorrect. |
{
"question_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"position": 0,
"kind": "true_false",
"prompt": "string",
"prompt_format": "plaintext",
"prompt_pdf_file_name": "string",
"explanation": "string",
"correct_boolean": true,
"correct_number": 0,
"tolerance": 0,
"options": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"is_correct": true,
"match_label": "string"
}
],
"response_boolean": true,
"selected_option_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"response_text": "string",
"response_number": 0,
"response_matches": [
{
"option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"match_option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}
],
"is_correct": true
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| label | string | yes | — |
| is_correct | boolean | yes | — |
| match_label | string | null | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"is_correct": true,
"match_label": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| question_id | string (uuid) | yes | — |
| response_boolean | boolean | no | — |
| selected_option_ids | array of string (uuid) | no | multiple_choice: exactly one id. multi_select: the chosen ids. ordering: every presented item id, in the taker's arrangement. 1–20 items |
| response_text | string | no | 1–500 characters |
| response_number | number | no | — |
| response_matches | array of ResponseMatch | no | 1–10 items |
{
"question_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"response_boolean": true,
"selected_option_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"response_text": "string",
"response_number": 0,
"response_matches": [
{
"option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"match_option_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of QuizAttempt | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scheduled_quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"attempt_number": 0,
"status": "in_progress",
"started_at": "2026-01-01T00:00:00.000Z",
"deadline_at": "2026-01-01T00:00:00.000Z",
"submitted_at": "2026-01-01T00:00:00.000Z",
"auto_submitted": true,
"question_count": 0,
"correct_count": 0,
"score_percentage": 0,
"passed": true,
"results_visible": true
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| label | string | no | Omit or send empty for an unlabelled sitting. 1–200 characters |
| opens_at | string (date-time) | yes | — |
| closes_at | string (date-time) | yes | — |
{
"label": "2026 annual recertification",
"opens_at": "2026-01-01T00:00:00.000Z",
"closes_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| label | string | null | no | Send null to clear the label. 1–200 characters |
| opens_at | string (date-time) | no | — |
| closes_at | string (date-time) | no | — |
{
"label": "string",
"opens_at": "2026-01-01T00:00:00.000Z",
"closes_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of QuizAssignment | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"assignee_kind": "role",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_name": "string",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"user_email": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| quiz_id | string (uuid) | yes | — |
| assignee_kind | string | yes | role: everyone granted the role. user: one directory member. one of "role" | "user" |
| role_id | string (uuid) | null | yes | — |
| role_name | string | null | yes | — |
| department_id | string (uuid) | null | yes | — |
| department_name | string | null | yes | — |
| user_id | string (uuid) | null | yes | User id of an individually assigned member. |
| user_display_name | string | null | yes | — |
| user_email | string | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"assignee_kind": "role",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_name": "string",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"user_email": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| role_id | string (uuid) | no | Assign to every holder of this department role. |
| user_id | string (uuid) | no | Assign to one member of the quiz's organization, by user id. |
{
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the quiz. |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| status | string | yes | Draft quizzes are invisible to assignees; published ones are takeable. one of "draft" | "published" |
| delivery_kind | string | yes | live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply. one of "live" | "async" |
| time_limit_seconds | integer | null | yes | — |
| selection_mode | string | yes | fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled. one of "fixed" | "shuffle" | "random_draw" |
| pass_percentage | number | yes | — |
| max_attempts | integer | null | yes | Null means unlimited attempts. |
| show_results_immediately | boolean | yes | Whether a taker sees score and pass/fail right after submitting. |
| show_correct_answers | boolean | yes | Whether answer review (your answer vs. correct) is ever shown to takers. |
| results_released_at | string (date-time) | null | yes | Manual release stamp for held-back results. An attempt's results are visible to its taker when show_results_immediately, once the sitting it was sat in has closed, or once this is set. |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
| bank_links | array of QuizBankLink | yes | The banks this quiz draws from, in presentation order. |
| subjects | array of QuizSubject | yes | Subject matters the per-taker learning record is filed under. |
| schedules | array of ScheduledQuiz | yes | The quiz's scheduled sittings in calendar order. A live quiz is takeable only while one of them is open; async quizzes ignore them. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"status": "draft",
"delivery_kind": "live",
"time_limit_seconds": 0,
"selection_mode": "fixed",
"pass_percentage": 0,
"max_attempts": 0,
"show_results_immediately": true,
"show_correct_answers": true,
"results_released_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z",
"bank_links": [
{
"question_bank_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"bank_slug": "string",
"bank_name": "string",
"position": 0,
"draw_count": 0,
"question_count": 0
}
],
"subjects": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string"
}
],
"schedules": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"quiz_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"label": "string",
"opens_at": "2026-01-01T00:00:00.000Z",
"closes_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| question_bank_id | string (uuid) | yes | — |
| bank_slug | string | yes | — |
| bank_name | string | yes | — |
| position | integer | yes | — |
| draw_count | integer | null | yes | Questions a random_draw quiz pulls from this bank; null = all. |
| question_count | integer | yes | Unarchived questions currently in the linked bank. |
{
"question_bank_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"bank_slug": "string",
"bank_name": "string",
"position": 0,
"draw_count": 0,
"question_count": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| links | array of object | yes | Replaces the quiz's bank links; array order is presentation order. at most 50 items |
{
"links": [
{
"question_bank_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"draw_count": 0
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| subject_matter_ids | array of string (uuid) | yes | Replaces the quiz's subject-matter tags. at most 50 items |
{
"subject_matter_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the quiz. |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| status | string | yes | Draft quizzes are invisible to assignees; published ones are takeable. one of "draft" | "published" |
| delivery_kind | string | yes | live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply. one of "live" | "async" |
| time_limit_seconds | integer | null | yes | — |
| selection_mode | string | yes | fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled. one of "fixed" | "shuffle" | "random_draw" |
| pass_percentage | number | yes | — |
| max_attempts | integer | null | yes | Null means unlimited attempts. |
| show_results_immediately | boolean | yes | Whether a taker sees score and pass/fail right after submitting. |
| show_correct_answers | boolean | yes | Whether answer review (your answer vs. correct) is ever shown to takers. |
| results_released_at | string (date-time) | null | yes | Manual release stamp for held-back results. An attempt's results are visible to its taker when show_results_immediately, once the sitting it was sat in has closed, or once this is set. |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"status": "draft",
"delivery_kind": "live",
"time_limit_seconds": 0,
"selection_mode": "fixed",
"pass_percentage": 0,
"max_attempts": 0,
"show_results_immediately": true,
"show_correct_answers": true,
"results_released_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of Quiz | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"status": "draft",
"delivery_kind": "live",
"time_limit_seconds": 0,
"selection_mode": "fixed",
"pass_percentage": 0,
"max_attempts": 0,
"show_results_immediately": true,
"show_correct_answers": true,
"results_released_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the quiz belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a quiz owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like fire-safety-basics. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| delivery_kind | string | yes | live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply. one of "live" | "async" |
| time_limit_seconds | integer | no | Per-attempt time limit in seconds; a live sitting's close still caps it. |
| selection_mode | string | yes | fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled. one of "fixed" | "shuffle" | "random_draw" |
| pass_percentage | number | yes | Score required to pass, as a percentage of the questions presented. minimum 0 · maximum 100 |
| max_attempts | integer | no | Omit for unlimited attempts. |
| show_results_immediately | boolean | no | Defaults to true. |
| show_correct_answers | boolean | no | Defaults to false. |
| bank_links | array of object | no | Question banks to link from the start, in presentation order; each must belong to the owning organization. Omit to link banks later via PUT /quizzes/{quiz_id}/banks. at most 50 items |
{
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "annual-fire-safety",
"name": "Annual fire safety quiz",
"description": "string",
"delivery_kind": "live",
"time_limit_seconds": 1200,
"selection_mode": "fixed",
"pass_percentage": 80,
"max_attempts": 3,
"show_results_immediately": true,
"show_correct_answers": true,
"bank_links": [
{
"question_bank_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"draw_count": 0
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| delivery_kind | string | no | live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply. one of "live" | "async" |
| time_limit_seconds | integer | null | no | Send null to remove the time limit. |
| selection_mode | string | no | fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled. one of "fixed" | "shuffle" | "random_draw" |
| pass_percentage | number | no | Score required to pass, as a percentage of the questions presented. minimum 0 · maximum 100 |
| max_attempts | integer | null | no | Send null for unlimited attempts. |
| show_results_immediately | boolean | no | — |
| show_correct_answers | boolean | no | — |
{
"slug": "string",
"name": "string",
"description": "string",
"delivery_kind": "live",
"time_limit_seconds": 1200,
"selection_mode": "fixed",
"pass_percentage": 80,
"max_attempts": 3,
"show_results_immediately": true,
"show_correct_answers": true
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of JourneyProgress | yes | Every unarchived journey assigned to the caller, with derived progress. |
{
"items": [
{
"journey_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description_markdown": "string",
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"levels": [
{
"level_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"description_markdown": "string",
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"position": 0,
"depends_on_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"state": "locked",
"applicable_count": 0,
"awarded_count": 0,
"has_overdue": true,
"certifications": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"applies_to_user": true,
"awarded": true,
"overdue": true
}
]
}
],
"current_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"complete": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| journey_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description_markdown | string | null | yes | — |
| attachments | array of DescriptionAttachment | yes | The journey's description attachments, in authored order. |
| levels | array of JourneyLevelProgress | yes | Every level in authored order, each with its derived state. |
| current_level_ids | array of string (uuid) | yes | The unlocked-but-incomplete levels — where the viewer is right now. |
| complete | boolean | yes | Whether every level is complete for the viewer. |
{
"journey_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description_markdown": "string",
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"levels": [
{
"level_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"description_markdown": "string",
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"position": 0,
"depends_on_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"state": "locked",
"applicable_count": 0,
"awarded_count": 0,
"has_overdue": true,
"certifications": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"applies_to_user": true,
"awarded": true,
"overdue": true
}
]
}
],
"current_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"complete": true
}| Field | Type | Required | Description |
|---|---|---|---|
| level_id | string (uuid) | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| description_markdown | string | null | yes | — |
| attachments | array of DescriptionAttachment | yes | The level's description attachments, in authored order. |
| position | integer | yes | — |
| depends_on_level_ids | array of string (uuid) | yes | — |
| state | string | yes | complete: every applicable certification is awarded (vacuously complete when none applies). unlocked: every prerequisite level is complete. locked: rendered collapsed — presentation only, nothing is enforced server-side. one of "locked" | "unlocked" | "complete" |
| applicable_count | integer | yes | Certifications in the level that apply to the viewer. |
| awarded_count | integer | yes | Of the applicable ones, how many the viewer currently holds. |
| has_overdue | boolean | yes | Whether any applicable certification in the level is past due — the warning badge on an otherwise complete level. Never re-locks anything. |
| certifications | array of JourneyCertProgress | yes | — |
{
"level_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"description_markdown": "string",
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"position": 0,
"depends_on_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"state": "locked",
"applicable_count": 0,
"awarded_count": 0,
"has_overdue": true,
"certifications": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"applies_to_user": true,
"awarded": true,
"overdue": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| certification_type_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| applies_to_user | boolean | yes | Whether one of the viewer's unexpired role grants requires this type. Only applicable certifications count towards level completion. |
| awarded | boolean | yes | Whether the viewer currently holds a non-revoked award of this type. |
| overdue | boolean | yes | Whether the requirement is past due for the viewer (initial or recertification). Always false on certifications that do not apply — a warning badge, never a lock. |
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"applies_to_user": true,
"awarded": true,
"overdue": true
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| journey_id | string (uuid) | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| description_markdown | string | null | yes | Long-form narrative markdown below the summary line; never parsed. |
| position | integer | yes | Authored order — the stable tie-break parallel branches render in. |
| depends_on_level_ids | array of string (uuid) | yes | Levels that must all be complete before this one unlocks; empty for a root level. |
| certifications | array of JourneyLevelCertification | yes | The certification types this level groups, in authored order. |
| attachments | array of DescriptionAttachment | yes | The level's description attachments, in authored order. The bytes are fetched through GET /journey-levels/{level_id}/attachments/{attachment_id}/download. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"journey_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"description_markdown": "string",
"position": 0,
"depends_on_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"certifications": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"archived": true
}
],
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| certification_type_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| archived | boolean | yes | Whether the certification type itself has been archived since placement. |
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"archived": true
}| Field | Type | Required | Description |
|---|---|---|---|
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| description_markdown | string | no | Omit or send empty for no long-form markdown description. at most 50000 characters |
| depends_on_level_ids | array of string (uuid) | no | Existing levels of the same journey this one depends on. The edges must keep the journey acyclic. Omit for a root level. at most 50 items |
| certification_type_ids | array of string (uuid) | no | Certification types of the same organization to place in this level, in order. A type may appear in at most one level per journey. at most 100 items |
{
"name": "Level 1: Foundations",
"description": "string",
"description_markdown": "string",
"depends_on_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"certification_type_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| description_markdown | string | null | no | Send null to clear the long-form markdown description. at most 50000 characters |
| position | integer | no | minimum 0 |
| depends_on_level_ids | array of string (uuid) | no | Replaces the level's whole prerequisite set; must stay acyclic. at most 50 items |
| certification_type_ids | array of string (uuid) | no | Replaces the level's whole certification list, in the given order. at most 100 items |
{
"name": "string",
"description": "string",
"description_markdown": "string",
"position": 0,
"depends_on_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"certification_type_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| journey_id | string (uuid) | yes | — |
| assignee_kind | string | yes | one of "role" | "department" | "organization" |
| role_id | string (uuid) | null | yes | — |
| role_name | string | null | yes | — |
| department_id | string (uuid) | null | yes | The role's department on a role assignment, the audience itself on a department one (covering departments nested beneath it too), null on an organization one. |
| department_name | string | null | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"journey_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"assignee_kind": "role",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_name": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| assignee_kind | string | yes | role: holders of role_id. department: everyone with a role grant in department_id or any department nested beneath it. organization: every member. one of "role" | "department" | "organization" |
| role_id | string (uuid) | no | Required for role assignments; must be a role of the journey's organization. |
| department_id | string (uuid) | no | Required for department assignments; forbidden for organization ones. |
{
"assignee_kind": "role",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of Journey | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description_markdown": "string",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| owner_organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Null when the organization itself owns the journey. |
| slug | string | yes | — |
| name | string | yes | — |
| description_markdown | string | null | yes | Narrative markdown rendered on the journey page; never parsed for structure. |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description_markdown": "string",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| owner_organization_id | string (uuid) | yes | Organization the journey belongs to. Always required. |
| owner_department_id | string (uuid) | no | Omit for a journey owned by the organization directly. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like new-hire-path. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description_markdown | string | no | Omit or send empty for no markdown description. at most 50000 characters |
{
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "new-hire-path",
"name": "New hire path",
"description_markdown": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| journey | Journey | yes | — |
| attachments | array of DescriptionAttachment | yes | The journey's description attachments, in authored order. The bytes are fetched through GET /journeys/{journey_id}/attachments/{attachment_id}/download. |
| levels | array of JourneyLevel | yes | Every level with its prerequisites and contents, in authored order. |
| assignments | array of JourneyAssignment | yes | Who the journey is presented to. |
{
"journey": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description_markdown": "string",
"archived_at": "2026-01-01T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
},
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"levels": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"journey_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"description_markdown": "string",
"position": 0,
"depends_on_level_ids": [
"1b671a64-40d5-491e-99b0-da01ff1f3341"
],
"certifications": [
{
"certification_type_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"archived": true
}
],
"attachments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"file_name": "string",
"content_type": "string",
"label": "string",
"position": 0
}
],
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"assignments": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"journey_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"assignee_kind": "role",
"role_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"role_name": "string",
"department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"department_name": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| slug | string | no | 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | no | 1–200 characters |
| description_markdown | string | null | no | Send null to clear the markdown description. at most 50000 characters |
{
"slug": "string",
"name": "string",
"description_markdown": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of ExternalLmsServer | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"base_url": "string",
"lms_kind": "string",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| slug | string | yes | — |
| name | string | yes | — |
| description | string | null | yes | — |
| base_url | string | null | yes | Where the server lives, for humans; the LRS never calls it. |
| lms_kind | string | null | yes | — |
| created_at | string (date-time) | yes | — |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "string",
"name": "string",
"description": "string",
"base_url": "string",
"lms_kind": "string",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| organization_id | string (uuid) | yes | Organization whose catalogue the server belongs to. |
| slug | string | yes | Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like moodle-eu. 1–100 characters · matches ^[a-z0-9]+(-[a-z0-9]+)*$ |
| name | string | yes | 1–200 characters |
| description | string | no | Omit or send empty for no description. at most 2000 characters |
| base_url | string (uri) | no | at most 2000 characters |
| lms_kind | string | no | Free-text kind of the server, e.g. "moodle" or "scorm-cloud"; informational. 1–100 characters |
{
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"slug": "moodle-eu",
"name": "Moodle (EU campus)",
"description": "string",
"base_url": "https://moodle.example.com",
"lms_kind": "moodle"
}| Field | Type | Required | Description |
|---|---|---|---|
| created | integer | yes | — |
| duplicates | integer | yes | — |
| errors | integer | yes | — |
| outcomes | array of ExternalGradeOutcome | yes | One outcome per submitted row, in row order. |
{
"created": 0,
"duplicates": 0,
"errors": 0,
"outcomes": [
{
"index": 0,
"status": "created",
"error_description": "string",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| index | integer | yes | 0-based position of the row in the submitted batch (or CSV data rows). |
| status | string | yes | one of "created" | "duplicate" | "error" |
| error_description | string | null | yes | Why the row was rejected; null unless status is error. |
| learning_record_id | string (uuid) | null | yes | The completed learning record a created row produced. |
{
"index": 0,
"status": "created",
"error_description": "string",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| file | string (binary) | yes | CSV file, at most 1 MB: a header row naming an `external_course_id`, `score` (0-100) and `completed_at` (ISO date) column, plus an `external_user_id` and/or `learner_user_id` column, in any order; `passed` and `external_attempt_id` columns are optional. Header matching is case-insensitive and extra columns are ignored. |
{
"file": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| grades | array of ExternalGradeRow | yes | 1–1000 items |
{
"grades": [
{
"external_course_id": "SAFETY-101",
"external_user_id": "jdoe",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"score": 87.5,
"passed": true,
"completed_at": "2026-01-01T00:00:00.000Z",
"external_attempt_id": "string"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| external_course_id | string | yes | The LMS's own course identifier, resolved against the server's catalogue. 1–200 characters |
| external_user_id | string | no | The LMS's identity of the learner, resolved through the server's user mappings. Provide this or user_id. 1–200 characters |
| user_id | string (uuid) | no | The directory member's users.id, for writers that already know it (e.g. manual entry). |
| score | number | yes | Normalized 0-100 score of the attempt. minimum 0 · maximum 100 |
| passed | boolean | no | The LMS's own verdict. Decides passing when the course sets no passing_score. |
| completed_at | string (date-time) | yes | When the attempt was completed on the LMS. |
| external_attempt_id | string | no | The LMS's own id of this attempt. The strongest duplicate fence: re-imports of the same id are skipped. Rows without one dedupe on (course, learner, completed_at). 1–200 characters |
{
"external_course_id": "SAFETY-101",
"external_user_id": "jdoe",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"score": 87.5,
"passed": true,
"completed_at": "2026-01-01T00:00:00.000Z",
"external_attempt_id": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of ExternalCourseGrade | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_name": "string",
"course_external_id": "string",
"learner_user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_user_id": "string",
"external_attempt_id": "string",
"score": 0,
"passed": true,
"completed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| course_id | string (uuid) | yes | — |
| course_name | string | yes | — |
| course_external_id | string | yes | — |
| learner_user_id | string (uuid) | yes | — |
| external_user_id | string | null | yes | — |
| external_attempt_id | string | null | yes | — |
| score | number | yes | — |
| passed | boolean | null | yes | — |
| completed_at | string (date-time) | yes | — |
| learning_record_id | string (uuid) | yes | — |
| created_at | string (date-time) | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_name": "string",
"course_external_id": "string",
"learner_user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_user_id": "string",
"external_attempt_id": "string",
"score": 0,
"passed": true,
"completed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of ExternalLmsUserMapping | yes | — |
{
"items": [
{
"server_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_user_id": "string",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"display_name": "string",
"email": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| server_id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| external_user_id | string | yes | — |
| user_id | string (uuid) | yes | — |
| display_name | string | null | yes | The member's profile name from users, for listings. |
| string | null | yes | — | |
| created_at | string (date-time) | yes | — |
{
"server_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_user_id": "string",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"display_name": "string",
"email": "string",
"created_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| external_user_id | string | yes | The LMS's own identity of the learner. Writing an existing one repoints it. 1–200 characters |
| user_id | string (uuid) | yes | users.id of the directory member the identity resolves to. |
{
"external_user_id": "jdoe",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of ExternalLmsCourse | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"server_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_id": "string",
"name": "string",
"description": "string",
"course_url": "string",
"passing_score": 0,
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| server_id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| external_course_id | string | yes | The LMS's own identifier — the key grades arrive under; unique per server. |
| name | string | yes | — |
| description | string | null | yes | — |
| course_url | string | null | yes | — |
| passing_score | number | null | yes | Score required to pass, 0-100. Authoritative when set; when null, the imported passed flag decides. |
| subject_matter_id | string (uuid) | null | yes | Subject matter imported grade records are tagged with; null = untagged. |
| created_at | string (date-time) | yes | — |
| archived_at | string (date-time) | null | yes | When set, the record is retired; it stays readable but should not grow. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"server_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"external_course_id": "string",
"name": "string",
"description": "string",
"course_url": "string",
"passing_score": 0,
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| external_course_id | string | yes | The LMS's own identifier of the course. Unique within the server; immutable. 1–200 characters |
| name | string | yes | 1–200 characters |
| description | string | no | at most 2000 characters |
| course_url | string (uri) | no | at most 2000 characters |
| passing_score | number | no | Score required to pass, 0-100. Omit to let the imported passed flag decide. minimum 0 · maximum 100 |
| subject_matter_id | string (uuid) | no | Subject matter of the type's own organization to tag imported grade records with. |
{
"external_course_id": "SAFETY-101",
"name": "Workplace safety basics",
"description": "string",
"course_url": "https://moodle.example.com/course/view.php?id=42",
"passing_score": 80,
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | Send null to clear the description. at most 2000 characters |
| base_url | string (uri) | null | no | Send null to clear the URL. at most 2000 characters |
| lms_kind | string | null | no | Send null to clear the kind. 1–100 characters |
{
"name": "string",
"description": "string",
"base_url": "string",
"lms_kind": "moodle"
}| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | at most 2000 characters |
| course_url | string (uri) | null | no | Send null to clear the URL. at most 2000 characters |
| passing_score | number | null | no | Send null to clear the pass mark (the imported flag then decides). minimum 0 · maximum 100 |
| subject_matter_id | string (uuid) | null | no | Send null to stop tagging imported records. |
{
"name": "string",
"description": "string",
"course_url": "string",
"passing_score": 0,
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of AvailableScormCourse | yes | — |
{
"items": [
{
"course": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scorm_version": "scorm-1.2",
"launch_path": "string",
"mastery_score": 0,
"package_file_name": "string",
"package_size_bytes": 0,
"created_by": "string",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
},
"organization_name": "string",
"in_progress_attempt": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"attempt_number": 0,
"status": "in_progress",
"api_flavor": "scorm-1.2",
"completion_status": "completed",
"success_status": "passed",
"score_raw": 0,
"score_max": 0,
"score_scaled": 0,
"progress_measure": 0,
"location": "string",
"total_time_seconds": 0,
"started_at": "2026-01-01T00:00:00.000Z",
"last_commit_at": "2026-01-01T00:00:00.000Z",
"completed_at": "2026-01-01T00:00:00.000Z",
"passed": true,
"closed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
},
"latest_attempt": null,
"attempts_used": 0
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| course | ScormCourse | yes | — |
| organization_name | string | yes | — |
| in_progress_attempt | ScormAttempt | yes | — |
| latest_attempt | any | yes | — |
| attempts_used | integer | yes | — |
{
"course": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scorm_version": "scorm-1.2",
"launch_path": "string",
"mastery_score": 0,
"package_file_name": "string",
"package_size_bytes": 0,
"created_by": "string",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
},
"organization_name": "string",
"in_progress_attempt": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"attempt_number": 0,
"status": "in_progress",
"api_flavor": "scorm-1.2",
"completion_status": "completed",
"success_status": "passed",
"score_raw": 0,
"score_max": 0,
"score_scaled": 0,
"progress_measure": 0,
"location": "string",
"total_time_seconds": 0,
"started_at": "2026-01-01T00:00:00.000Z",
"last_commit_at": "2026-01-01T00:00:00.000Z",
"completed_at": "2026-01-01T00:00:00.000Z",
"passed": true,
"closed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
},
"latest_attempt": null,
"attempts_used": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| owner_department_id | string (uuid) | null | yes | Owning department within the organization; null = owned by the organization directly. Scopes which scorm:read/scorm:write grants cover the course, and narrows who may take it: an organization-owned course is open to every member, a department-owned one only to the owning department's role holders. |
| name | string | yes | — |
| description | string | null | yes | — |
| subject_matter_id | string (uuid) | null | yes | Subject matter completions are tagged with; null = untagged. |
| scorm_version | string | yes | SCORM revision detected from the package's imsmanifest.xml. one of "scorm-1.2" | "scorm-2004" | "unknown" |
| launch_path | string | yes | Zip-entry path (optionally with a query string) of the launch resource. |
| mastery_score | number | null | yes | SCORM 1.2 adlcp:masteryscore (0-100) from the manifest, if declared. |
| package_file_name | string | yes | — |
| package_size_bytes | integer | yes | — |
| created_by | string | null | yes | — |
| created_at | string (date-time) | yes | — |
| archived_at | string (date-time) | null | yes | An archived course is hidden from learners and takes no new attempts. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scorm_version": "scorm-1.2",
"launch_path": "string",
"mastery_score": 0,
"package_file_name": "string",
"package_size_bytes": 0,
"created_by": "string",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| course_id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | — |
| attempt_number | integer | yes | — |
| status | string | yes | one of "in_progress" | "closed" |
| api_flavor | string | null | yes | one of "scorm-1.2" | "scorm-2004" | null |
| completion_status | string | yes | one of "completed" | "incomplete" | "not_attempted" | "unknown" |
| success_status | string | yes | one of "passed" | "failed" | "unknown" |
| score_raw | number | null | yes | — |
| score_max | number | null | yes | — |
| score_scaled | number | null | yes | 0..1 (SCORM 2004 allows -1..1); from cmi.score.scaled, else raw/min/max. |
| progress_measure | number | null | yes | — |
| location | string | null | yes | Where the learner left off (cmi location), as the module reported it. |
| total_time_seconds | number | yes | Learning time accumulated across every session of this attempt. |
| started_at | string (date-time) | yes | — |
| last_commit_at | string (date-time) | null | yes | — |
| completed_at | string (date-time) | null | yes | — |
| passed | boolean | null | yes | From success_status: passed/failed => true/false, unknown => null. |
| closed_at | string (date-time) | null | yes | — |
| learning_record_id | string (uuid) | null | yes | The learning record this attempt's completion reported into, if any. |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"attempt_number": 0,
"status": "in_progress",
"api_flavor": "scorm-1.2",
"completion_status": "completed",
"success_status": "passed",
"score_raw": 0,
"score_max": 0,
"score_scaled": 0,
"progress_measure": 0,
"location": "string",
"total_time_seconds": 0,
"started_at": "2026-01-01T00:00:00.000Z",
"last_commit_at": "2026-01-01T00:00:00.000Z",
"completed_at": "2026-01-01T00:00:00.000Z",
"passed": true,
"closed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| attempt | ScormAttempt | yes | — |
| course | ScormCourse | yes | — |
| entry | string | yes | resume seeds the saved CMI map so the module restores its state. one of "ab-initio" | "resume" |
| cmi | object | yes | SCORM CMI elements by name, e.g. cmi.core.lesson_status. |
| total_time_seconds | number | yes | — |
| objective_ids | array of string | yes | Manifest-declared objectives the runtime seeds, like a real LMS. |
| mastery_score | number | null | yes | — |
| learner_id | string | yes | — |
| learner_name | string | yes | — |
| content_url | string | yes | Where the launch resource is served from. Relative asset URLs inside the module resolve under the same signed content prefix. |
{
"attempt": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"attempt_number": 0,
"status": "in_progress",
"api_flavor": "scorm-1.2",
"completion_status": "completed",
"success_status": "passed",
"score_raw": 0,
"score_max": 0,
"score_scaled": 0,
"progress_measure": 0,
"location": "string",
"total_time_seconds": 0,
"started_at": "2026-01-01T00:00:00.000Z",
"last_commit_at": "2026-01-01T00:00:00.000Z",
"completed_at": "2026-01-01T00:00:00.000Z",
"passed": true,
"closed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
},
"course": {
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scorm_version": "scorm-1.2",
"launch_path": "string",
"mastery_score": 0,
"package_file_name": "string",
"package_size_bytes": 0,
"created_by": "string",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
},
"entry": "ab-initio",
"cmi": {},
"total_time_seconds": 0,
"objective_ids": [
"string"
],
"mastery_score": 0,
"learner_id": "string",
"learner_name": "string",
"content_url": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| restart | boolean | no | True closes any attempt in progress and starts a fresh one; the default resumes it. |
{
"restart": true
}| Field | Type | Required | Description |
|---|---|---|---|
| cmi | object | yes | SCORM CMI elements by name, e.g. cmi.core.lesson_status. |
| api_flavor | string | null | yes | one of "scorm-1.2" | "scorm-2004" | null |
| terminated | boolean | yes | True once the module has called LMSFinish / Terminate this session. |
{
"cmi": {},
"api_flavor": "scorm-1.2",
"terminated": true
}| Field | Type | Required | Description |
|---|---|---|---|
| pathname | string | yes | Server-chosen blob pathname the signed upload URL writes to. |
| upload_url | string | yes | — |
| expires_at | string (date-time) | yes | — |
{
"pathname": "string",
"upload_url": "string",
"expires_at": "2026-01-01T00:00:00.000Z"
}| Field | Type | Required | Description |
|---|---|---|---|
| org | string | yes | Organization UUID id or slug the course will belong to. at least 1 character |
| department | string | no | UUID id or slug of the owning department within the organization; omit for a course owned by the organization directly. at least 1 character |
| file_name | string | yes | 1–255 characters |
| size_bytes | integer | yes | Declared package size; the upload token enforces the cap. maximum 52428800 |
{
"org": "acme-corporation",
"department": "string",
"file_name": "string",
"size_bytes": 0
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of ScormCourse | yes | — |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"owner_department_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"name": "string",
"description": "string",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"scorm_version": "scorm-1.2",
"launch_path": "string",
"mastery_score": 0,
"package_file_name": "string",
"package_size_bytes": 0,
"created_by": "string",
"created_at": "2026-01-01T00:00:00.000Z",
"archived_at": "2026-01-01T00:00:00.000Z"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
| org | string | yes | Organization UUID id or slug the course belongs to. at least 1 character |
| department | string | no | UUID id or slug of the owning department within the organization; omit for a course owned by the organization directly. Ownership scopes administration and limits taking the course to the department's role holders. at least 1 character |
| pathname | string | yes | matches ^scorm-courses\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[^/]{1,100}$ |
| file_name | string | yes | 1–255 characters |
| name | string | no | Defaults to the title in the package manifest, then the file name. at most 200 characters |
| description | string | no | at most 5000 characters |
| subject_matter_id | string (uuid) | no | — |
{
"org": "acme-corporation",
"department": "string",
"pathname": "string",
"file_name": "string",
"name": "string",
"description": "string",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341"
}| Field | Type | Required | Description |
|---|---|---|---|
| items | array of ScormCourseAttempt | yes | — |
| next_cursor | string | null | yes | Pass as ?cursor= to fetch the next page; null on the last page. |
{
"items": [
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"attempt_number": 0,
"status": "in_progress",
"api_flavor": "scorm-1.2",
"completion_status": "completed",
"success_status": "passed",
"score_raw": 0,
"score_max": 0,
"score_scaled": 0,
"progress_measure": 0,
"location": "string",
"total_time_seconds": 0,
"started_at": "2026-01-01T00:00:00.000Z",
"last_commit_at": "2026-01-01T00:00:00.000Z",
"completed_at": "2026-01-01T00:00:00.000Z",
"passed": true,
"closed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"user_email": "string"
}
],
"next_cursor": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| id | string (uuid) | yes | — |
| course_id | string (uuid) | yes | — |
| organization_id | string (uuid) | yes | — |
| user_id | string (uuid) | yes | — |
| attempt_number | integer | yes | — |
| status | string | yes | one of "in_progress" | "closed" |
| api_flavor | string | null | yes | one of "scorm-1.2" | "scorm-2004" | null |
| completion_status | string | yes | one of "completed" | "incomplete" | "not_attempted" | "unknown" |
| success_status | string | yes | one of "passed" | "failed" | "unknown" |
| score_raw | number | null | yes | — |
| score_max | number | null | yes | — |
| score_scaled | number | null | yes | 0..1 (SCORM 2004 allows -1..1); from cmi.score.scaled, else raw/min/max. |
| progress_measure | number | null | yes | — |
| location | string | null | yes | Where the learner left off (cmi location), as the module reported it. |
| total_time_seconds | number | yes | Learning time accumulated across every session of this attempt. |
| started_at | string (date-time) | yes | — |
| last_commit_at | string (date-time) | null | yes | — |
| completed_at | string (date-time) | null | yes | — |
| passed | boolean | null | yes | From success_status: passed/failed => true/false, unknown => null. |
| closed_at | string (date-time) | null | yes | — |
| learning_record_id | string (uuid) | null | yes | The learning record this attempt's completion reported into, if any. |
| user_display_name | string | null | yes | — |
| user_email | string | null | yes | — |
{
"id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"course_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"organization_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"attempt_number": 0,
"status": "in_progress",
"api_flavor": "scorm-1.2",
"completion_status": "completed",
"success_status": "passed",
"score_raw": 0,
"score_max": 0,
"score_scaled": 0,
"progress_measure": 0,
"location": "string",
"total_time_seconds": 0,
"started_at": "2026-01-01T00:00:00.000Z",
"last_commit_at": "2026-01-01T00:00:00.000Z",
"completed_at": "2026-01-01T00:00:00.000Z",
"passed": true,
"closed_at": "2026-01-01T00:00:00.000Z",
"learning_record_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"user_display_name": "string",
"user_email": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| name | string | no | 1–200 characters |
| description | string | null | no | at most 5000 characters |
| subject_matter_id | string (uuid) | null | no | — |
| archived | boolean | no | True archives the course (hidden from learners); false restores it. |
{
"name": "string",
"description": "string",
"subject_matter_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"archived": true
}| Field | Type | Required | Description |
|---|---|---|---|
| outcome | string | yes | `sent`: the mail server accepted the email. `dry_run`: built and validated, not sent. `skipped_empty`: nothing falls in a window and include_empty was false. one of "sent" | "dry_run" | "skipped_empty" |
| audience | string | yes | Whose deadlines the digest covers: `trainee` — the user's own certification requirements; `manager` — the requirements of every trainee holding a role the user manages (scoped to those managed roles). one of "trainee" | "manager" |
| user_id | string (uuid) | yes | Who the digest covers. |
| to | string | null | yes | The recipient address used, or null when the digest was skipped. |
| subject | string | null | yes | The email's subject line, or null when the digest was skipped. |
| total_items | integer | yes | Requirements in the digest across all windows. minimum 0 |
| window_counts | object | yes | Requirements per window: overdue, and due in 10/30/60 days. |
| preview_text | string | null | yes | The plaintext body that was (or, for dry_run, would have been) sent; the HTML variant is rendered from the same digest. |
{
"outcome": "sent",
"audience": "trainee",
"user_id": "1b671a64-40d5-491e-99b0-da01ff1f3341",
"to": "string",
"subject": "string",
"total_items": 0,
"window_counts": {
"overdue": 0,
"next_10_days": 0,
"next_30_days": 0,
"next_60_days": 0
},
"preview_text": "string"
}| Field | Type | Required | Description |
|---|---|---|---|
| user | string (uuid) | yes | `users.id` of the user the digest is about — and, unless `email` overrides it, the recipient (resolved from their account's profile). |
| audience | string | yes | Whose deadlines the digest covers: `trainee` — the user's own certification requirements; `manager` — the requirements of every trainee holding a role the user manages (scoped to those managed roles). one of "trainee" | "manager" |
| string (email) | no | Send to this address instead of the user's account email. | |
| dry_run | boolean | no | Build, render and validate the email without contacting the mail server. Defaults to false. |
| include_empty | boolean | no | Send an "all clear" email even when no requirement is overdue or due within 60 days. Defaults to false (such digests are skipped). |
{
"user": "6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7",
"audience": "trainee",
"email": "someone@example.com",
"dry_run": true,
"include_empty": true
}| Field | Type | Required | Description |
|---|---|---|---|
| display_name | string | null | yes | The friendly deployment name, or null when the Botree default is shown. |
| logo | DeploymentBrandingLogo | yes | — |
| updated_at | string (date-time) | yes | — |
{
"display_name": "Acme Training Records",
"logo": {
"file_name": "string",
"content_type": "string",
"url": "/api/branding/logo?v=1735689600000"
},
"updated_at": "2026-01-01T00:00:00.000Z"
}The uploaded logo, or null when the Botree artwork is shown.
| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | yes | The uploaded file's original name. |
| content_type | string | yes | The image's content type. |
| url | string | yes | Where the logo is served from, with a cache-busting version query. Public: the header and footer load it as a plain image. |
{
"file_name": "string",
"content_type": "string",
"url": "/api/branding/logo?v=1735689600000"
}| Field | Type | Required | Description |
|---|---|---|---|
| display_name | string | null | yes | The friendly name to show; send null or an empty string to restore the default. at most 100 characters |
{
"display_name": "Acme Training Records"
}| Field | Type | Required | Description |
|---|---|---|---|
| file | string (binary) | yes | The logo image, as a file part: PNG, JPEG, WebP, GIF or SVG, at most 2 MB. Square artwork fits the header best. |
{
"file": "string"
}