SCORM API Reference
Complete reference documentation for all SCORM API endpoints, request/response schemas, authentication requirements, and error handling.
Table of Contents
- Overview
- Authentication
- Base URLs
- API Versioning
- Rate Limiting
- Error Handling
- API Endpoints
- Request/Response Examples
Overview
The SCORM API is a RESTful API that provides endpoints for managing SCORM packages, learning sessions, analytics, and integrations. All endpoints return JSON responses and use standard HTTP status codes.
API Features
- SCORM 1.2 & 2004 Support: Full support for both SCORM versions
- Multi-tenant Architecture: Complete data isolation per tenant
- Optimistic Locking: Version-based concurrency control
- Rate Limiting: Per-tenant rate limits to ensure fair usage
- Webhooks: Real-time event notifications
- xAPI Integration: Automatic SCORM to xAPI conversion
Authentication
The SCORM API supports two authentication methods:
API Key Authentication
For programmatic access (server-to-server), use API key authentication:
X-API-Key: your-api-key-here
OR
Authorization: Bearer your-api-key-here
API Key Scopes:
| Scope | Permissions |
|---|---|
public_api |
Standard partner access (upload, launch, sessions, dispatches, webhooks) |
internal_product |
Internal product/service access (elevated operations) |
admin |
System administration |
Note: Fine-grained scopes may be added in future releases.
Clerk Authentication
For web application users (browser-based), authentication is handled via Clerk session cookies. Customer routes (/api/customer/*) automatically filter data by the authenticated user's tenant.
See: API Key Security Guide for detailed authentication documentation.
Base URLs
Production: https://app.allureconnect.com
Development: http://localhost:3000
API Versioning
The API uses URL-based versioning:
- v1:
/api/v1/*- Current stable version - Customer Routes:
/api/customer/*- Web application routes (Clerk auth) - Admin Routes:
/api/admin/*- System administration routes
Rate Limiting
Rate limits are applied per endpoint and keyed by API key or workspace:
| Endpoint | Default limit | Scope |
|---|---|---|
POST /api/v1/packages/upload-url (and /upload-sessions) |
120 req/min | per API key |
POST /api/v1/packages/process |
60 req/min | per workspace |
There is no single global per-scope (read/write/admin) RPM table. Limits may vary by deployment and plan; consult the rate-limit response headers or Rate Limiting Guide for details.
Rate Limit Headers (when present):
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1697544600
See: Rate Limiting Guide for detailed information.
Error Handling
All errors follow this standard format:
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": {
"field": "Additional context"
}
}
HTTP Status Codes:
| Code | Meaning | Common Error Codes |
|---|---|---|
200 |
Success | - |
201 |
Created | - |
400 |
Bad Request | INVALID_REQUEST, MISSING_FILE, INVALID_FILE_TYPE |
401 |
Unauthorized | API_KEY_REQUIRED |
403 |
Forbidden | TENANT_MISMATCH, dispatch-origin errors |
404 |
Not Found | PACKAGE_NOT_FOUND, SESSION_NOT_FOUND |
409 |
Conflict | VERSION_CONFLICT |
413 |
Payload Too Large | FILE_TOO_LARGE |
429 |
Too Many Requests | RATE_LIMIT_EXCEEDED |
500 |
Internal Server Error | INTERNAL_ERROR |
See: Error Codes Reference for complete error code documentation.
API Endpoints
Health Check
GET /api/health
Check if the API is running and healthy.
Authentication: None required
Response (200):
{
"status": "ok",
"timestamp": "2025-01-15T10:30:00.000Z",
"version": "1.0.0",
"database": "connected",
"storage": "available"
}
Packages
Unsupported legacy path (not implemented): POST /api/v1/packages
Not implemented in the current Connect route handler (GET lists packages only). Do not use this path for uploads.
Use instead:
POST /api/v1/packages/upload-url(or aliasPOST /api/v1/packages/upload-sessions) to mint a presigned URLPUTthe ZIP topresigned_urlwithrequired_put_headersPOST /api/v1/packages/processto validate and publish
The endpoint sequence below is the canonical public partner upload flow.
For small direct multipart uploads (subject to platform body limits), the app exposes POST /api/v1/packages/upload — see the OpenAPI spec (GET /api/docs/openapi).
GET /api/v1/packages
List all packages for the authenticated tenant.
Authentication: API Key (public_api, internal_product, or admin scope)
Query Parameters:
tenant_id(deprecated optional compatibility parameter): Ignored; the API key always determines the tenant.limit(optional): Number of results (default: 50, max: 500)offset(optional): Pagination offset (default: 0)
Response (200):
{
"packages": [
{
"id": "pkg_abc123",
"tenantId": "tenant_456",
"slug": "introduction-to-safety-training",
"title": "Introduction to Safety Training",
"contentType": "scorm",
"status": "ready",
"currentVersion": "1.2",
"launchCount30d": 18,
"storageMb": 5,
"updatedAt": "2026-09-01T12:00:00.000Z"
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"hasMore": false,
"totalIsLowerBound": false
}
}
Advance offset by limit while hasMore is true. If
totalIsLowerBound is true, total is a bounded lower estimate rather than
an exact catalog count.
GET /api/v1/packages/{packageId}
Get detailed information about a specific package, including its version history.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200):
{
"package": {
"id": "pkg_abc123",
"tenantId": "tenant_456",
"slug": "introduction-to-safety-training",
"title": "Introduction to Safety Training",
"contentType": "scorm",
"status": "ready",
"currentVersion": "1.2",
"launchCount30d": 18,
"storageMb": 5,
"updatedAt": "2026-09-01T12:00:00.000Z"
},
"versions": [
{
"id": "ver_1",
"packageId": "pkg_abc123",
"versionLabel": "1.2",
"contentType": "scorm",
"status": "ready",
"manifestVersion": "1.2",
"uploadedAt": "2026-09-01T12:00:00.000Z",
"storageKey": "packages/tenant_456/pkg_abc123/1.2/course.zip"
}
]
}
DELETE /api/v1/packages/{packageId}
Soft-archive a package. The package is marked as archived and hidden from default list results, but is not permanently deleted from storage. Storage quota is not immediately freed. To permanently delete a package and release storage, use the Connect dashboard.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200): Archive result
Note:
PATCH /api/v1/packages/{packageId}is not implemented. Package metadata updates are available via the Connect dashboard.
POST /api/v1/packages/{packageId}/launch
Create a new session and get the player URL.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"user_id": "660e8400-e29b-41d4-a716-446655440000",
"session_id": "770e8400-e29b-41d4-a716-446655440000"
}
Response (200):
{
"launch_url": "https://app.allureconnect.com/player/770e8400-e29b-41d4-a716-446655440000?token=eyJhbGciOi...",
"session_id": "770e8400-e29b-41d4-a716-446655440000",
"package_id": "pkg_abc123",
"learner_id": "660e8400-e29b-41d4-a716-446655440000",
"content_type": "scorm",
"expires_in_seconds": 14400
}
launch_urlalready embeds the signed session token (?token=<jwt>). Treat it as opaque — do not strip query parameters or hand-reconstruct the player URL.
GET /api/v1/packages/{packageId}/versions
Get version history for a package.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200):
{
"versions": [
{
"revision": 3,
"created_at": "2025-01-15T10:30:00.000Z",
"uploaded_by": "user-123",
"file_size_bytes": 5242880
}
]
}
POST /api/v1/packages/multipart/init
Initialize multipart upload for large packages.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"tenant_id": "tenant_example-workspace",
"uploaded_by": "user-123",
"filename": "large-course.zip",
"file_size": 262144000
}
file_size is required so the server can enforce the effective upload ceiling
(the lower of the configured deployment ceiling—500 MB by default—and the plan tier)
at init (413 FILE_TOO_LARGE), plan the exact part count, and sign each part
URL for its expected byte length.
Response (200):
{
"upload_id": "upload_abc123",
"multipart_upload_id": "2~x9Yf...r2-issued-id",
"storage_path": "tenant/multipart/tmp_123.zip",
"part_size_bytes": 52428800,
"max_upload_mb": 500,
"part_count": 5
}
POST /api/v1/packages/multipart/part-url
Get presigned URL for uploading a part.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"upload_id": "upload_abc123",
"part_number": 1
}
Response (200):
{
"url": "https://storage.example.com/upload?presigned=...",
"expires_in": 3600,
"content_length_bytes": 52428800
}
PUT exactly content_length_bytes raw bytes to url and save the ETag response header for the
complete call (the R2 bucket CORS policy must expose ETag for browser
clients).
POST /api/v1/packages/multipart/complete
Complete multipart upload.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"upload_id": "upload_abc123",
"parts": [
{ "part_number": 1, "etag": "\"etag1\"" },
{ "part_number": 2, "etag": "\"etag2\"" }
]
}
Response (200):
{
"upload_id": "upload_abc123",
"storage_path": "tenant/multipart/tmp_123.zip",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"uploaded_by": "user-123",
"filename": "large-course.zip",
"next_step": "Call POST /api/v1/packages/process with tenant_id, uploaded_by, storage_path, original_filename"
}
POST /api/v1/packages/multipart/abort
Abort an in-flight multipart upload and free its staged parts in storage. Call
this when an upload is interrupted or abandoned so orphaned parts do not
accumulate; then retry with a fresh init.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"upload_id": "upload_abc123"
}
Response (200):
{
"upload_id": "upload_abc123",
"storage_path": "tenant/multipart/tmp_123.zip",
"status": "aborted",
"aborted": true
}
Idempotent: repeat aborts return "status": "already_aborted", and aborting a
finished upload returns "status": "already_completed" (the stored object is
untouched), both with "aborted": false. Unknown upload ids return 404
UPLOAD_NOT_FOUND.
Package upload options
POST /api/v1/packages/process
Process an uploaded package (from multipart or direct upload).
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"uploaded_by": "user-123",
"storage_path": "tenant/uploads/tmp_123.zip",
"original_filename": "course.zip",
"validate_only": false
}
Response (200): Processing result (upload_id, manifest, optional package with package_id, etc.) — see OpenAPI ProcessPackageResponse at GET /api/docs/openapi.
POST /api/v1/packages/upload-url
Get a presigned URL for HTTP PUT direct upload to object storage (Cloudflare R2 when configured). Same behavior as POST /api/v1/packages/upload-sessions (alias).
Authentication: API key (public_api, internal_product, or admin scope) — Authorization: Bearer <api_key> or X-API-Key: <api_key>.
Request Body (OpenAPI schema UploadUrlRequest):
| Field | Type | Required | Notes |
|---|---|---|---|
tenant_id |
string | Yes | Must match the tenant for the API key; server resolves tenant from the key. |
uploaded_by |
string | Yes | Defaults to the API key id if omitted or empty. |
filename |
string | Yes | Must end in .zip. |
file_size |
number | Yes | Size in bytes; effective limit is the lower of the deployment ceiling and tenant plan. |
{
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"uploaded_by": "user-123",
"filename": "course.zip",
"file_size": 10485760
}
Response (200): (uploadUrlResponseSchema)
{
"upload_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"storage_path": "tenant/uploads/abc/course.zip",
"presigned_url": "https://<r2-host>/<bucket>/...",
"storage_type": "r2",
"bucket": "<configured-bucket>",
"max_upload_mb": 500,
"presigned_expires_in_seconds": 3600,
"presigned_expires_at": "2026-04-14T12:00:00.000Z",
"upload_method": "PUT",
"required_put_headers": {
"Content-Type": "application/zip"
},
"expires_at": "2026-04-14T12:00:00.000Z"
}
Send PUT to presigned_url with the ZIP body and at least Content-Type: application/zip as required by required_put_headers. Then call POST /api/v1/packages/process with storage_path and related fields.
Response (413): The shared UploadPlanLimitResponse includes
max_upload_mb, limit_source, and suggested_plan. When the current platform
ceiling binds, suggested_plan is null; do not direct the customer to upgrade.
Use the endpoint sequence in this section as the canonical public partner upload flow.
GET /api/v1/packages/upload-limit
Get the configured maximum upload size and storage backend flags.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200): (uploadLimitResponseSchema)
{
"max_upload_mb": 500,
"storage_type": "r2",
"r2_enabled": true
}
Sessions
GET /api/v1/sessions
List and filter sessions.
Authentication: API Key (public_api, internal_product, or admin scope)
Query Parameters:
user_id(optional): Filter by user IDpackage_id(optional): Filter by package IDlimit(optional): Results per page (default: 20, max: 100)offset(optional): Row offset (default: 0). Takes precedence overpagepage(deprecated optional alias): Resolved asoffset = (page - 1) * limit
Tenancy is derived from the API key, so there is no
tenant_idparameter. Any other query parameter is ignored rather than rejected — a request that sendscompletion_status,success_status,date_from,date_to,sort_by, orsort_orderreceives an unfiltered, unsorted page.Filtering and sorting are therefore client-side and page-local: each response is one page, so filtering it yields matches within that page only, and sorting it cannot produce a globally ordered result. To filter or sort across the whole set, advance
offsetwhilehas_moreis true (or narrow the set server-side withuser_id/package_id), then apply the operation to the accumulated rows.
Response (200):
{
"sessions": [
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"session_id": "770e8400-e29b-41d4-a716-446655440000",
"package_id": "pkg_abc123",
"tenant_id": "tenant_456",
"user_id": "user_abc",
"learner_id": "user_abc",
"learner_name": "Jordan Lee",
"status": "completed",
"completion_status": "completed",
"progress_percent": 100,
"last_activity_at": "2026-09-01T13:00:00.000Z",
"content_type": "scorm",
"packageId": "pkg_abc123",
"learnerName": "Jordan Lee",
"completionStatus": "completed",
"progressPercent": 100,
"lastActivityAt": "2026-09-01T13:00:00.000Z"
}
],
"pagination": {
"total": 150,
"limit": 20,
"offset": 0,
"has_more": true,
"hasMore": true,
"total_is_lower_bound": false,
"totalIsLowerBound": false,
"page": 1,
"total_pages": 8,
"has_next": true,
"has_prev": false
}
}
Use offset and has_more for new integrations. The page, total_pages,
has_next, and has_prev fields remain available as deprecated compatibility
aliases. When total_is_lower_bound is true, total and total_pages are
lower bounds; continue while has_more is true.
GET /api/v1/sessions/{sessionId}
Get session data and CMI information.
Authentication: API Key (public_api, internal_product, or admin scope) OR Launch token
Query Parameters:
token(optional): Launch token for player access
Response (200):
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "660e8400-e29b-41d4-a716-446655440000",
"package_id": "pkg_abc123",
"cmi_data": {
"cmi.core.lesson_status": "incomplete",
"cmi.core.score.raw": "75",
"cmi.core.score.max": "100",
"cmi.core.session_time": "PT15M30S"
},
"completion_status": "incomplete",
"success_status": "unknown",
"score": {
"scaled": 0.75,
"raw": 75,
"max": 100,
"min": 0
},
"time_spent_seconds": 930,
"version": 3,
"created_at": "2025-01-15T10:00:00.000Z",
"updated_at": "2025-01-15T10:15:30.000Z"
}
PUT /api/v1/sessions/{sessionId}
Update session data and CMI information.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"version": 3,
"cmi_data": {
"cmi.core.lesson_status": "completed",
"cmi.core.score.raw": "85",
"cmi.core.score.max": "100",
"cmi.core.session_time": "PT20M45S"
},
"completion_status": "completed",
"success_status": "passed",
"score": {
"scaled": 0.85,
"raw": 85,
"max": 100,
"min": 0
},
"session_time": "PT20M45S"
}
Important: The version field is optional, and sending it is what enables optimistic locking. Include it on normal commits: if you receive a 409 Conflict, fetch the latest session data and retry with the updated version. Omitting it opts out of conflict detection — the write becomes last-write-wins, always applies, and never returns 409. Reserve that for a final best-effort flush (for example a page-unload commit) where losing the write matters more than overwriting a newer revision.
Response (200): Updated session object
Error Responses:
409- Version conflict (fetch latest and retry)503- Temporary backend failure. Ifretryableis true, wait for theRetry-Afterdelay and retry the same commit
POST /api/v1/sessions/{sessionId}/refresh-token
Refresh a bearer token for the same active SCORM session.
Authentication: Launch token (Authorization: Bearer <launch-token>) or tenant API key
Response (200):
{
"success": true,
"session_id": "770e8400-e29b-41d4-a716-446655440000",
"token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in_seconds": 14400,
"launch_url": "https://app.allureconnect.com/player/770e8400-e29b-41d4-a716-446655440000?token=eyJhbGciOi..."
}
Use launch_url as-is if you need to reopen the hosted player. This endpoint does not persist CMI; the player (or a partner PUT) does.
Error Responses:
503- Temporary backend failure. Ifretryableis true, wait for theRetry-Afterdelay and retry
Session list, detail, update, and token-refresh operations use the same structured transient failure response:
HTTP/1.1 503 Service Unavailable
Retry-After: 5
Content-Type: application/json
{
"error": "The learning record backend is temporarily unavailable. Retry the request.",
"code": "BACKEND_UNAVAILABLE",
"retryable": true
}
Dispatches
GET /api/v1/dispatches
List dispatch packages.
Authentication: API Key (public_api, internal_product, or admin scope)
The tenant is resolved from the API key. This endpoint currently returns the complete tenant-scoped list and does not accept pagination parameters.
Response (200):
{
"dispatches": [
{
"id": "dispatch_123",
"tenantId": "tenant_456",
"packageId": "pkg_abc123",
"label": "Client Distribution",
"destination": "client-lms",
"status": "active",
"launches30d": 25,
"seatsUsed": 25,
"seatsCap": 100,
"expiresAt": "2026-10-01T12:00:00.000Z",
"updatedAt": "2026-09-01T12:00:00.000Z"
}
]
}
POST /api/v1/dispatches
Create a dispatch package.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"package_id": "pkg_abc123",
"label": "Client Distribution",
"destination": "acme-lms",
"registration_limit": 100,
"expires_in_hours": 720,
"allowed_domains": ["acme.com", "training.acme.com"]
}
Response (201):
{
"dispatch": {
"id": "dispatch-123",
"packageId": "pkg_abc123",
"package_id": "pkg_abc123",
"tenantId": "tenant_456",
"tenant_id": "tenant_456",
"label": "Client Distribution",
"destination": "acme-lms",
"launchUrl": "https://app.allureconnect.com/player/dispatch/dispatch-123",
"launch_url": "https://app.allureconnect.com/player/dispatch/dispatch-123",
"status": "active"
},
"package_id": "pkg_abc123",
"dispatch_token": "<legacy-token>",
"dispatch_url": "https://app.allureconnect.com/player/dispatch/dispatch-123"
}
GET /api/v1/dispatches/{dispatchId}
Get dispatch package details.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200): Dispatch detail object (see OpenAPI getDispatch)
PATCH /api/v1/dispatches/{dispatchId}
Update the organization/customer-group attribution for a dispatch (updateDispatchAttribution). This is the only partner-API PATCH operation on dispatches — it does not update label, expiry, registration limit, or allowed domains (those are managed via the Connect dashboard / customer routes).
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body (one field required):
| Field | Type | Description |
|---|---|---|
organization_id |
string | null |
Organization ID to assign; null clears attribution |
organizationId |
string | null |
Camel-case alias for organization_id |
Response (200): Updated dispatch with id, organizationId, organization_id, organizationName, and launch_url
Note:
DELETE /api/v1/dispatches/{id},GET .../zip, andGET .../launchesare not implemented at the partner API (/api/v1/) level. To download a SCORM ZIP or view launch statistics for a dispatch, use the Connect dashboard or the Clerk-authenticated customer routes (/api/customer/dispatches/{id}/zip).
POST /api/v1/dispatches/launch
Launch a dispatch package (for third-party LMSs).
Authentication: Launch token (included in dispatch package)
Request Body:
{
"token": "eyJhbGciOi...",
"external_registration_id": "registration-456",
"access_password": "optional-password"
}
Send either user_id or external_registration_id. access_password is
required only when the dispatch has learner access protection enabled. The
launch endpoint is public because the signed dispatch token is the credential;
do not add an API key or put the dispatch token in the query string.
Response (200):
{
"success": true,
"dispatch_id": "dispatch-123",
"package_id": "pkg_abc123",
"session_id": "550e8400-e29b-41d4-a716-446655440001",
"launch_url": "https://app.allureconnect.com/player/550e8400-e29b-41d4-a716-446655440001?token=eyJhbGciOi...",
"content_type": "scorm"
}
Launch Link Health Check (self-service)
Integrators who have a launch URL but aren't sure whether it will work can validate it structurally before embedding it. The endpoint is unauthenticated and read-only — it never returns session data, only whether the URL is well-formed and the token (if present) is valid.
GET /api/v1/launch-links/validate?url=…
POST /api/v1/launch-links/validate { "url": "…" }
Response (200): always 200. The status field distinguishes outcomes.
{
"status": "ok",
"message": "Session token is valid and not expired.",
"remediation": "No action needed — this URL should load for learners.",
"detail": {
"host": "app.allureconnect.com",
"pathname": "/player/ses_1",
"tokenPresent": true,
"linkType": "session"
}
}
status values:
| Status | Meaning |
|---|---|
ok |
URL is valid, token is valid, link should load. |
missing_token |
/player/<id> without a ?token=, or a truncated dispatch URL. |
expired_token |
Token parsed but past its TTL — re-mint. |
invalid_token |
Token failed signature verification — regenerate from the correct environment. |
not_a_player_url |
URL doesn't match a player surface path. |
invalid_url |
URL wasn't provided or couldn't be parsed. |
Use this endpoint from your integrator tooling (CLI, CI, integration tests) to catch broken launch URLs before they ship to an LMS.
Webhooks
GET /api/v1/webhooks
List webhooks for a tenant.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200):
{
"endpoints": [
{
"id": "webhook-123",
"tenantId": "tenant_example-workspace",
"label": "Production LMS",
"url": "https://hooks.example.com/scorm",
"eventScope": "sessions",
"status": "active",
"mode": "live",
"signingSecretConfigured": true
}
],
"deliveries": []
}
POST /api/v1/webhooks
Create a webhook endpoint. Subscribe to an event scope (packages,
sessions, usage, dispatches, competency, or all) — not a single event
type.
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"url": "https://hooks.example.com/scorm",
"secret": "replace-with-at-least-16-random-characters",
"label": "Production LMS",
"eventScope": "sessions"
}
The tenant is resolved from the API key — tenant_id is not required in the body.
Response (201):
{
"webhook": {
"id": "whk_abc123",
"url": "https://hooks.example.com/scorm",
"eventScope": "sessions",
"status": "active",
"signingSecretConfigured": true
}
}
See: Webhook Setup Guide for detailed webhook documentation.
xAPI
POST /api/v1/xapi/statements
Create xAPI statements (Learning Record Store).
Authentication: API Key (public_api, internal_product, or admin scope)
Request Body:
{
"actor": {
"mbox": "mailto:learner@example.com",
"name": "John Doe"
},
"verb": {
"id": "http://adlnet.gov/expapi/verbs/completed",
"display": { "en-US": "completed" }
},
"object": {
"id": "https://example.com/activities/course-123",
"definition": {
"name": { "en-US": "Safety Training" }
}
},
"result": {
"score": {
"scaled": 0.85,
"raw": 85,
"max": 100
},
"success": true,
"completion": true
}
}
Response (201):
{
"success": true,
"statement_ids": ["550e8400-e29b-41d4-a716-446655440000"]
}
The response includes X-Experience-API-Version: 1.0.3. The request body may
be one statement or an array of statements.
GET /api/v1/xapi/statements
Query xAPI statements.
Authentication: API Key (public_api, internal_product, or admin scope)
Query Parameters:
statement_id(optional): exact statement IDactor_mbox(optional): bare actor email (for example,learner@example.com)verb_id(optional): exact verb URLactivity_id(optional): exact activity/object IDsession_id(optional): Connect session IDpackage_id(optional): Connect package IDpage(optional): positive integer, default 1limit(optional): 1–500, default 25
Response (200):
{
"statements": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"actor": { ... },
"verb": { ... },
"object": { ... },
"timestamp": "2025-01-15T10:30:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 25,
"total": 1,
"totalPages": 1
}
}
GET /api/v1/xapi/statements/{statementId}
Get a specific xAPI statement.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200): xAPI statement object
GET /api/v1/xapi/analytics/activities
Get activity analytics.
Authentication: API Key (public_api, internal_product, or admin scope)
The tenant is resolved from the API key. Filters include activity_id,
activity_type, package_id, min_learners, sorting, and pagination. Use
OpenAPI for the exact parameter set.
Response (200):
{
"activities": [
{
"activity_id": "https://example.com/activities/course-123",
"activity_type": "http://adlnet.gov/expapi/activities/course",
"package_id": "pkg_abc123",
"total_statements": 150,
"unique_learners": 45,
"completed_count": 30,
"passed_count": 27,
"failed_count": 3,
"avg_score_scaled": 0.88
}
]
}
GET /api/v1/xapi/analytics/actors
Get actor analytics.
Authentication: API Key (public_api, internal_product, or admin scope)
The tenant is resolved from the API key. Filters include actor mailbox, minimum activity count, sorting, and pagination. Use OpenAPI for the exact parameter set.
Response (200):
{
"actors": [
{
"actor_mbox": "learner@example.com",
"actor_name": "John Doe",
"total_statements": 25,
"unique_activities": 5,
"completed_count": 5,
"passed_count": 4,
"failed_count": 1,
"avg_score_scaled": 0.86
}
]
}
Quotas
GET /api/v1/quotas
Get quota information for the authenticated tenant.
Authentication: API Key (public_api, internal_product, or admin scope)
Response (200):
{
"usage": {
"tenantId": "tenant_example-workspace",
"plan": "growth",
"packageLimit": 100,
"packageCount": 45,
"storageLimitGb": 50,
"storageUsedGb": 20,
"monthlyLaunchLimit": 10000,
"monthlyLaunches": 3200,
"launchesRolling30d": 3100,
"webhookFailureCount": 0,
"includedActiveLearners": 1500,
"activeLearners": 420
},
"max_upload_mb": 500
}
Use this section and the interactive OpenAPI reference for quota details.
Content
GET /api/v1/content/{packageId}/...
Serve SCORM package content files.
Authentication: Launch token or API Key (public_api, internal_product, or admin scope)
Path Parameters:
packageId: Package UUID...: Relative path to content file within package
Response (200): File content with appropriate Content-Type header
Example:
GET /api/v1/content/pkg_abc123/index.html
GET /api/v1/content/pkg_abc123/assets/styles.css
Customer Routes
Customer routes use Clerk session authentication (browser cookie) and automatically filter data by the authenticated user's tenant. They are not part of the partner API key surface.
For the live, authoritative catalog of customer routes, use the interactive OpenAPI docs at /api/docs (Scalar UI) or download the spec from GET /api/docs/openapi.
Sample real paths (as of this writing — see app/api/customer/ for the canonical inventory):
| Method | Path | Description |
|---|---|---|
GET |
/api/customer/packages |
List packages (dashboard view) |
GET/DELETE |
/api/customer/packages/{id} |
Get or archive a package |
GET/POST |
/api/customer/dispatches |
List or create dispatches |
GET/PATCH/DELETE |
/api/customer/dispatches/{id} |
Manage a dispatch |
GET |
/api/customer/dispatches/{id}/zip |
Download dispatch SCORM ZIP |
GET/POST |
/api/customer/webhooks |
Manage webhook endpoints |
GET |
/api/customer/api-keys/{apiKeyId}/activity |
API key activity log |
GET |
/api/customer/billing |
Billing info |
POST |
/api/customer/billing/portal |
Billing portal URL |
GET/POST |
/api/customer/reports/* |
Learner progress reports |
GET |
/api/customer/activity |
Tenant activity feed |
GET/POST |
/api/customer/organizations |
Organization management |
GET/POST |
/api/customer/connections/* |
Connector integrations |
GET/POST |
/api/customer/competency/* |
Competency/skill graph (CALE) — see /api/docs |
Credentials: Your Account ID and API keys are at Dashboard → Integrations → API Keys (or
/dashboard/integrations?tab=keys) — not under Settings → Tenant Details.
Admin Routes
Admin routes require system administrator (Clerk) authentication and can access all tenants. They are internal/ops surfaces, not partner-facing.
For the live catalog, use GET /api/docs/openapi or browse app/api/admin/.
Sample real paths:
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/tenants |
List all tenants |
GET |
/api/admin/packages |
Packages across all tenants |
GET |
/api/admin/audit-logs |
Audit logs |
GET |
/api/admin/metrics/usage |
Usage metrics |
GET |
/api/admin/webhooks |
All webhook endpoints |
GET |
/api/admin/contracts |
Billing contracts |
GET |
/api/admin/integration-incidents |
Integration incident log |
Request/Response Examples
Complete Integration Example
Start with the Custom LMS Integration, then choose the relevant platform guide:
Handling Version Conflicts
Include the version field on normal commits so concurrent writes are detected (omit it
only for a deliberate last-write-wins flush, which never conflicts):
async function retryableBackendDelay(response: Response, attempt: number) {
if (response.status !== 503) return null;
const failure = await response.clone().json().catch(() => null);
if (failure?.retryable !== true) return null;
const retryAfter = response.headers.get('Retry-After');
const retryAfterSeconds = retryAfter === null ? Number.NaN : Number(retryAfter);
return Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
? retryAfterSeconds * 1000
: Math.pow(2, attempt) * 1000;
}
async function updateSessionWithRetry(sessionId: string, updates: any, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
// Step 1: Get current session
const readResponse = await fetch(`/api/v1/sessions/${sessionId}`, {
headers: { 'X-API-Key': apiKey }
});
if (!readResponse.ok) {
const retryDelay = await retryableBackendDelay(readResponse, attempt);
if (retryDelay !== null && attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
continue;
}
throw new Error(`Session read failed: ${readResponse.status}`);
}
const session = await readResponse.json();
if (!Number.isInteger(session.version)) {
throw new Error('Session read did not return a valid version');
}
// Step 2: Merge your changes
const mergedData = {
...session.cmi_data,
...updates.cmi_data
};
// Step 3: Attempt update with current version
const response = await fetch(`/api/v1/sessions/${sessionId}`, {
method: 'PUT',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
...updates,
version: session.version, // Always use the version just retrieved
cmi_data: mergedData
})
});
if (response.ok) {
return await response.json(); // Success!
}
if (response.status === 409) {
console.log(`Version conflict, retrying... (${attempt + 1}/${maxRetries})`);
continue; // Retry with fresh data
}
const retryDelay = await retryableBackendDelay(response, attempt);
if (retryDelay !== null && attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
continue;
}
throw new Error(`Update failed: ${response.status}`);
}
throw new Error('Max retries exceeded for version conflict');
}
Error Handling with Retry
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
const method = (options.method ?? 'GET').toUpperCase();
const headers = new Headers(options.headers);
const mayReplay =
['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'].includes(method) ||
headers.has('Idempotency-Key');
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Don't retry client errors (4xx)
if (response.status >= 400 && response.status < 500) {
return response;
}
// Success
if (response.ok) {
return response;
}
const failure = await response.clone().json().catch(() => null);
if (!mayReplay || failure?.retryable !== true || attempt === maxRetries - 1) {
return response;
}
const retryAfter = response.headers.get('Retry-After');
const retryAfterSeconds = retryAfter === null ? Number.NaN : Number(retryAfter);
const delay =
Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
? retryAfterSeconds * 1000
: Math.pow(2, attempt) * 1000;
console.log(`Retryable server error, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
} catch (error) {
// Network errors have no response payload. Replay only requests whose
// method or idempotency key makes a retry safe.
if (mayReplay && attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000;
console.log(`Network error, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
throw new Error('Max retries exceeded');
}
Additional Resources
- Error Codes Reference - Complete error code documentation
- Rate Limiting Guide - Rate limiting details
- Quotas - Quota information
- Webhook Setup Guide - Webhook configuration
- CMI Data Guide - Understanding SCORM CMI data
- Package upload options - SCORM package upload and validation
API Version: 1.0.0