LMS Integration API
Base URL: /api/v1/lms
Connector definitions, LMS instances and credentials, routing rules, course shells, enrollment and grade sync, drift findings, SIS CSV export, and the job and integration logs.
This replaced an earlier API
Migration 199 created the lms schema and 202 dropped the legacy records.lms_* tables. The old surface — /platforms, /platforms/{id}/urls, /platforms/{id}/campuses, /credentials/{id}, /sync-logs — no longer exists. If you are calling any of those, you are calling a subsystem that was removed.
Setup instructions live in Setting Up LMS Integration; the schema and engine internals are in LMS Integration Design.
Permission model
Four nodes gate this domain rather than one:
| Node | Covers |
|---|---|
ADMIN.LMS.CONNECTORS | Connector types, operations, operation versions |
ADMIN.LMS.INSTANCES | Instances, credentials, mappings, rules, section overrides, policy |
ADMIN.LMS.SECTIONS | Provisioning, shells, drift findings, CSV exports |
ADMIN.LMS.LOGS | Integration log, job queue, and operation preview |
Two placements are deliberate:
- Preview is gated on
ADMIN.LMS.LOGS, not on the connector editor. Previewing an operation renders it against real student data. - Export download is gated on
ADMIN.LMS.SECTIONSlike the rest of that surface, even though it is aGET. The files are education records.
Connector types and operations
A connector type is one kind of LMS; its operations describe how to call it. Operation definitions are versioned JSONB — creating a version adds to the history rather than overwriting, so a change can be previewed before it takes effect.
GET /api/v1/lms/connector-types{
"results": [
{
"connector_type_id": 1,
"code": "CANVAS",
"name": "Instructure Canvas",
"auth_strategy_code": "BEARER",
"pagination_style": "LINK_HEADER",
"default_rate_limit_rps": "5.00",
"default_rate_burst": 10,
"is_active": true
}
]
}Auth strategies: BEARER, QUERY_API_KEY, D2L_OAUTH2, OAUTH2_CLIENT_CREDENTIALS. Pagination styles: NONE, LINK_HEADER, BOOKMARK.
Seeded types are CANVAS (16 operations), MOODLE (11) and BRIGHTSPACE (10). Operation codes include verify, create_course, find_course, get_course, conclude_course, create_section, find_user, find_user_by_login, create_user, enroll_user, unenroll_user, list_enrollments, sis_import, sis_import_status, export_profile and post_grade. An operation a connector does not define is not available — Moodle has no post_grade, so a Moodle instance is offered no grade passback.
POST /api/v1/lms/operations/{operationID}/previewRenders the operation against real data and returns what would be sent. Gated on ADMIN.LMS.LOGS.
Instances
One instance is one LMS tenant. Several may be active at once.
POST /api/v1/lms/instances{
"connector_type_id": 1,
"code": "CANVAS-PROD",
"name": "Canvas (production)",
"base_url": "https://canvas.example.edu",
"is_sandbox": false,
"dry_run": true,
"match_strategy": "SIS_ID",
"drop_behavior": "DEACTIVATE",
"create_missing_users": true,
"grade_passback_enabled": false,
"shell_name_template": "{{course.code}} - {{section.number}} ({{term.name}})",
"rate_limit_rps": "5.00"
}| Field | Values | Default |
|---|---|---|
dry_run | bool — render and log, never send | false |
match_strategy | SIS_ID, EMAIL | SIS_ID |
drop_behavior | DEACTIVATE, DELETE, CONCLUDE | DEACTIVATE |
create_missing_users | bool (migration 252) | true |
grade_passback_enabled | bool | false |
is_sandbox, is_active | bool | false / true |
rate_limit_rps, rate_burst | override the type's defaults | inherit |
test-connection runs the connector's verify operation:
POST /api/v1/lms/instances/{instanceID}/test-connectionCredentials
Secrets are write-only: never returned by the API, never displayed. Listing returns metadata only — purpose, client ID, encryption key ID, activation and retirement timestamps, and rotated_from_credential_id.
POST /api/v1/lms/instances/{instanceID}/credentials{
"purpose": "API",
"client_id": "…",
"secret": "…",
"token_url": "https://auth.example.edu/oauth/token",
"scopes": "core:*:*",
"activate": true
}Activating a credential retires the incumbent in the same transaction. A credential can also be retired with no successor, which stops the integration rather than leaving a leaked token in use.
503, not 409
With no keyring configured (LMS_CRED_KEYS / LMS_CRED_ACTIVE_KEY, or the SIS_-prefixed forms on the server) this endpoint returns 503 Service Unavailable with "LMS credential encryption keys are not configured on this server".
Nothing about the request is wrong — the server has no key to seal the secret with. This was formerly reported as a 409, which read as "duplicate credential" and sent callers looking for a row that never existed. A duplicate is not reachable here: the only unique index is partial (WHERE is_active), and creation retires the incumbent in the same transaction.
Configuration endpoints keep working without a keyring; only secret-touching paths fail.
Mappings
Term, role and org-unit mappings translate SIS identifiers into the LMS's. All three use PUT to upsert.
PUT /api/v1/lms/instances/{instanceID}/term-mappings
PUT /api/v1/lms/instances/{instanceID}/role-mappings
PUT /api/v1/lms/instances/{instanceID}/org-unit-mappingsA section in a term with no term mapping produces no shell. The reason is recorded in the integration log, not returned as an error on the provisioning call.
Routing: rules, overrides, policy
GET /api/v1/lms/rules
POST /api/v1/lms/rules
DELETE /api/v1/lms/rules/{ruleID}
PUT /api/v1/lms/sections/{sectionID}/override
GET /api/v1/lms/policy
PATCH /api/v1/lms/policyAssignment rules route sections to an instance by org_unit_id and/or campus_id with a numeric priority (default 100, lowest wins). A section override pins one section to one instance regardless of the rules.
The policy is global, not per instance:
| Field | Default |
|---|---|
provisioning_mode | OPTIONAL |
provision_trigger, provision_days_before | MANUAL, 30 |
enrollment_sync_trigger, enrollment_days_before | MANUAL, 7 |
conclude_policy, conclude_days_after | LEAVE_OPEN, 30 |
drift_missing_enrollment | FLAG |
drift_extra_enrollment | FLAG |
drift_missing_shell | FLAG |
Shells and sync
POST /api/v1/lms/provision
GET /api/v1/lms/dashboard
GET /api/v1/lms/shells
GET /api/v1/lms/shells/{shellID}/links
DELETE /api/v1/lms/shells/{shellID}
POST /api/v1/lms/shells/{shellID}/sync-enrollments
POST /api/v1/lms/shells/{shellID}/sync-grades
POST /api/v1/lms/shells/{shellID}/sections
DELETE /api/v1/lms/shells/{shellID}/sections/{sectionID}sis_course_id (migration 251) records the SIS course id actually sent, so a shell stays traceable after it is renamed in the LMS. DELETE /shells/{shellID} abandons a shell that never reached the LMS and refuses once the shell holds a course. A shell may carry several sections, which is how cross-listed sections share one LMS course.
Grade passback
sync-grades publishes final grades for a shell, subject to the release gate.
A grade is publishable when its latest change is Colleague-sourced or a registrar release covers it (records.grade_releases, migration 203). Two properties:
- Fails closed — with no release gate wired, passback holds everything back.
- No history flood — the first scan for an instance only initialises its watermark to the current maximum. Afterwards the scanner walks
records.grade_change_logforward, and the release hook enqueues held grades as soon as the registrar acts.
Drift findings
GET /api/v1/lms/drift-findings
POST /api/v1/lms/drift-findings/{findingID}/resolveDifferences between what the SIS believes and what the LMS reports: missing enrollments, extra enrollments, missing shells.
SIS CSV export
GET /api/v1/lms/exports
POST /api/v1/lms/exports
POST /api/v1/lms/exports/preview
GET /api/v1/lms/exports/{runID}
POST /api/v1/lms/exports/{runID}/upload
POST /api/v1/lms/exports/{runID}/cancel
GET /api/v1/lms/exports/{runID}/files/{fileID}/downloadEach connector's export_profile operation describes the CSV shape. One active run per instance is enforced by the database, so a second concurrent run is rejected rather than queued.
Jobs and logs
GET /api/v1/lms/jobs
POST /api/v1/lms/jobs/{jobID}/retry
POST /api/v1/lms/jobs/{jobID}/cancel
GET /api/v1/lms/integration-logsThe queue is worked with SKIP LOCKED, so workers do not contend. The dead letter is a status filter on /jobs, not a separate resource.
Complete endpoint reference
Every route this domain registers, with the permission node that gates it. Generated from the route table, so it is complete by construction — the sections above explain behaviour, this one guarantees nothing is missing.
Paths are relative to /api/v1/lms.
| Method | Endpoint | Permission node | Handler |
|---|---|---|---|
| GET | /connector-types | ADMIN.LMS.CONNECTORS | ListConnectorTypes |
| GET | /connector-types/{typeID}/operations | ADMIN.LMS.CONNECTORS | ListOperations |
| POST | /connector-types/{typeID}/operations | ADMIN.LMS.CONNECTORS | CreateOperation |
| GET | /operations/{operationID}/versions | ADMIN.LMS.CONNECTORS | ListVersions |
| POST | /operations/{operationID}/versions | ADMIN.LMS.CONNECTORS | CreateVersion |
| GET | /versions/{versionID} | ADMIN.LMS.CONNECTORS | GetVersion |
| GET | /instances | ADMIN.LMS.INSTANCES | ListInstances |
| POST | /instances | ADMIN.LMS.INSTANCES | CreateInstance |
| GET | /instances/{instanceID} | ADMIN.LMS.INSTANCES | GetInstance |
| PATCH | /instances/{instanceID} | ADMIN.LMS.INSTANCES | UpdateInstance |
| DELETE | /instances/{instanceID} | ADMIN.LMS.INSTANCES | DeleteInstance |
| POST | /instances/{instanceID}/test-connection | ADMIN.LMS.INSTANCES | TestConnection |
| GET | /instances/{instanceID}/credentials | ADMIN.LMS.INSTANCES | ListCredentials |
| POST | /instances/{instanceID}/credentials | ADMIN.LMS.INSTANCES | CreateCredential |
| POST | /instances/{instanceID}/credentials/{credentialID}/activate | ADMIN.LMS.INSTANCES | ActivateCredential |
| POST | /instances/{instanceID}/credentials/{credentialID}/deactivate | ADMIN.LMS.INSTANCES | DeactivateCredential |
| DELETE | /instances/{instanceID}/credentials/{credentialID} | ADMIN.LMS.INSTANCES | DeleteCredential |
| GET | /instances/{instanceID}/term-mappings | ADMIN.LMS.INSTANCES | ListTermMappings |
| PUT | /instances/{instanceID}/term-mappings | ADMIN.LMS.INSTANCES | UpsertTermMapping |
| DELETE | /instances/{instanceID}/term-mappings/{mappingID} | ADMIN.LMS.INSTANCES | DeleteTermMapping |
| GET | /instances/{instanceID}/role-mappings | ADMIN.LMS.INSTANCES | ListRoleMappings |
| PUT | /instances/{instanceID}/role-mappings | ADMIN.LMS.INSTANCES | UpsertRoleMapping |
| GET | /instances/{instanceID}/org-unit-mappings | ADMIN.LMS.INSTANCES | ListOrgUnitMappings |
| PUT | /instances/{instanceID}/org-unit-mappings | ADMIN.LMS.INSTANCES | UpsertOrgUnitMapping |
| DELETE | /instances/{instanceID}/org-unit-mappings/{mappingID} | ADMIN.LMS.INSTANCES | DeleteOrgUnitMapping |
| GET | /rules | ADMIN.LMS.INSTANCES | ListAssignmentRules |
| POST | /rules | ADMIN.LMS.INSTANCES | CreateAssignmentRule |
| DELETE | /rules/{ruleID} | ADMIN.LMS.INSTANCES | DeleteAssignmentRule |
| PUT | /sections/{sectionID}/override | ADMIN.LMS.INSTANCES | UpsertSectionOverride |
| GET | /policy | ADMIN.LMS.INSTANCES | GetPolicy |
| PATCH | /policy | ADMIN.LMS.INSTANCES | UpdatePolicy |
| POST | /provision | ADMIN.LMS.SECTIONS | ProvisionNow |
| GET | /dashboard | ADMIN.LMS.SECTIONS | Dashboard |
| GET | /shells | ADMIN.LMS.SECTIONS | ListShells |
| GET | /shells/{shellID}/links | ADMIN.LMS.SECTIONS | ShellLinks |
| DELETE | /shells/{shellID} | ADMIN.LMS.SECTIONS | DeleteShell |
| POST | /shells/{shellID}/sync-enrollments | ADMIN.LMS.SECTIONS | SyncEnrollmentsNow |
| POST | /shells/{shellID}/sync-grades | ADMIN.LMS.SECTIONS | SyncGradesNow |
| POST | /shells/{shellID}/sections | ADMIN.LMS.SECTIONS | AddShellSection |
| DELETE | /shells/{shellID}/sections/{sectionID} | ADMIN.LMS.SECTIONS | RemoveShellSection |
| GET | /drift-findings | ADMIN.LMS.SECTIONS | ListFindings |
| POST | /drift-findings/{findingID}/resolve | ADMIN.LMS.SECTIONS | ResolveFinding |
| GET | /exports | ADMIN.LMS.SECTIONS | ListExports |
| POST | /exports | ADMIN.LMS.SECTIONS | CreateExport |
| POST | /exports/preview | ADMIN.LMS.SECTIONS | PreviewExport |
| GET | /exports/{runID} | ADMIN.LMS.SECTIONS | GetExport |
| POST | /exports/{runID}/upload | ADMIN.LMS.SECTIONS | UploadExport |
| POST | /exports/{runID}/cancel | ADMIN.LMS.SECTIONS | CancelExport |
| GET | /exports/{runID}/files/{fileID}/download | ADMIN.LMS.SECTIONS | DownloadExportFile |
| POST | /operations/{operationID}/preview | ADMIN.LMS.LOGS | Preview |
| GET | /jobs | ADMIN.LMS.LOGS | ListJobs |
| POST | /jobs/{jobID}/retry | ADMIN.LMS.LOGS | RetryJob |
| POST | /jobs/{jobID}/cancel | ADMIN.LMS.LOGS | CancelJob |
| GET | /integration-logs | ADMIN.LMS.LOGS | ListIntegrationLog |
54 routes: 6 on ADMIN.LMS.CONNECTORS, 25 on ADMIN.LMS.INSTANCES, 18 on ADMIN.LMS.SECTIONS, 5 on ADMIN.LMS.LOGS.