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-hereOR
Authorization: Bearer your-api-key-hereAPI 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:3000API 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: 1697544600See: 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": "SCORM 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",
"new_attempt": false
}session_id(optional): reuse this session when it exists (idempotent relaunch).runtime_protocol(optional):exclusive_v1opts this authenticated launch into exclusive hosted-runtime ownership. Omit it to preserve the legacy hosted-player and partner-write contract. The choice is stored on the session and remains in effect on resume and reset; omission does not downgrade an opted-in session. An authorized launch can upgrade its existing scoped session without resetting CMI or progress.new_attempt(optional): retake. Starts a fresh attempt on the existingsession_id, with fresh status, score, suspend data and time. Earlier attempts keep their results inattempt_historyand reports. Repeating the call while the new attempt is still untouched reuses that attempt.
Retakes are driven by your LMS. Send new_attempt: true on launch, or call POST /sessions/{id}/reset, when a learner should do the course over.
Relaunching a completed session without new_attempt follows the course's attempt setting (Dashboard → Course → Overview → Attempts and relaunch):
completedRelaunchBehavior | What the learner gets |
|---|---|
resume (courses created before this setting) | The completed attempt reopens and keeps recording, but completion can't regress. |
review (default for new courses) | The completed attempt in SCORM review mode (cmi.mode = review, cmi.credit = no-credit), with nothing recorded. The player explains that the learner's LMS can start a new attempt. |
new_attempt | A fresh attempt automatically. |
The hosted player's own Start over button is off by default. You can turn it on per course or per dispatch (learner_start_over_allowed on PATCH /api/v1/dispatches/{id}).
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,
"attempt_number": 1
}
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,
"attempts": 2,
"attempt_number": 2,
"launch_mode": "normal",
"start_over_allowed": false,
"attempt_history": [
{ "attempt_number": 2, "completion_status": "incomplete", "success_status": "unknown", "score": { "scaled": 0.75, "raw": 75 }, "time_spent_seconds": 930, "progress_percent": 40, "started_at": "2025-01-15T10:00:00.000Z", "last_activity_at": "2025-01-15T10:15:30.000Z", "superseded_at": null, "superseded_reason": null },
{ "attempt_number": 1, "completion_status": "passed", "success_status": "passed", "score": { "scaled": 0.92, "raw": 92 }, "time_spent_seconds": 1480, "progress_percent": 100, "started_at": "2025-01-10T09:00:00.000Z", "last_activity_at": "2025-01-10T09:25:00.000Z", "superseded_at": "2025-01-15T09:59:00.000Z", "superseded_reason": "api_reset" }
],
"version": 3,
"created_at": "2025-01-15T10:00:00.000Z",
"updated_at": "2025-01-15T10:15:30.000Z"
}Status, score and time describe the current attempt. attempt_history lists up to 20 attempts, newest first. launch_mode is review when the hosted player opens a completed attempt read-only.
POST /api/v1/sessions/{sessionId}
For a session explicitly launched with runtime_protocol: "exclusive_v1", claim a hosted-player runtime for the current attempt before starting its course iframe. This is an authenticated state change, not a new learner attempt. Use the same tenant-authorized API key or session launch token accepted by PUT; a runtime UUID is an ownership identifier, not a credential.
Read the session first. The nested session.runtimeProtocol is legacy or exclusive_v1. The shared hosted player claims only exclusive_v1; claiming a legacy session returns 409 RUNTIME_PROTOCOL_NOT_ENABLED. Do not use claim to implicitly migrate a legacy integration. Send a newly generated UUID for this player instance, the observed nested session.runtimeOwnerId (or null when unclaimed), and the observed session.attempts:
{
"runtime_id": "48bc29ec-d648-4ffd-b5db-8231e1c3d8d2",
"expected_runtime_id": null,
"attempt": 2
}The body is strict: both runtime identifiers must be UUIDs (expected_runtime_id may be null), and attempt must be a positive integer. A successful 200 returns { "session": ... }, the fresh canonical camel-case player detail, including runtimeProtocol, runtimeOwnerId and runtimeMode (record or review). Initialize the course from this returned snapshot, not an earlier server-rendered or GET snapshot.
Ownership changes atomically only when the expected owner and attempt still match. Retrying the same runtime claim is idempotent and preserves its recording/review mode. 409 RUNTIME_SUPERSEDED or 409 ATTEMPT_SUPERSEDED means this runtime must stop; do not refresh the version and replay its stale writes.
A completed-attempt review cannot displace a recording owner that may still be saving final answers. A review with no owner is recorded as read-only ownership and cannot submit progress. Reset/start-over creates a fresh, unclaimed attempt while preserving the session protocol; old runtimes cannot write into it. Legacy sessions continue to support mixed hosted-player and partner API writes, including omitted-version updates. Any existing runtime owner still enforces ownership and attempt checks; the protocol label never disables an established write fence.
PUT /api/v1/sessions/{sessionId}
Update session data and CMI information.
Authentication: API Key (public_api, internal_product, or admin scope) OR session launch token
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: Include version on normal commits for optimistic locking. Only 409 VERSION_CONFLICT permits fetching fresh state and retrying with its version. Omitting version skips the version check; it does not bypass runtime ownership, attempt fencing, authorization or review restrictions. A best-effort unload can still be rejected after another runtime takes ownership or a reset starts a new attempt.
Response (200): Updated session object
A completed or passed attempt never moves back to incomplete through PUT. To let a learner do the course again, start a new attempt: use new_attempt on launch, POST /sessions/{id}/reset, or the player's Start over.
attempt is the positive integer attempt number the writer loaded. It remains optional for an unclaimed legacy attempt; once claimed, both the exact attempt and owning runtime_id UUID are required on every write, including unload commits. A retake/reset returns 409 ATTEMPT_SUPERSEDED; a missing or stale runtime, missing exact attempt, or read-only review owner returns 409 RUNTIME_SUPERSEDED. Stop that writer instead of rebasing it onto a new owner or attempt.
Error Responses:
409-VERSION_CONFLICT(fetch latest and retry),ATTEMPT_SUPERSEDED, orRUNTIME_SUPERSEDED(stop the stale writer; open the current session afresh)503- Temporary backend failure. Ifretryableis true, wait for theRetry-Afterdelay and retry the same commit
POST /api/v1/sessions/{sessionId}/reset
Mark a learner's session incomplete, for example when your LMS admin resets course progress. This starts a new attempt with fresh status, score, suspend data and time. The previous attempt keeps its completion, score and time in attempt_history and reports.
Authentication: API Key (public_api, internal_product, or admin scope). The session must belong to the key's workspace and mode (a TEST key never resets a LIVE session).
Request Body (optional):
{ "reason": "LMS admin reset course progress" }Response (200): the session in the same shape as PUT, plus "reset": true. The response has "reset": false when the current attempt was already untouched, so nothing changed.
A session.reset event is recorded in the audit log with the actor (API key), the reason and the previous attempt's state, and it is delivered to sessions webhooks.
Error Responses:
400- Invalid session ID or body404-SESSION_NOT_FOUND(unknown, or another workspace's session)
POST /api/v1/sessions/{sessionId}/start-over
The hosted player's optional Start over control. It is authenticated with the learner's launch token (Authorization: Bearer <launch-token>) for this session. It is off unless the course or the dispatch opts in (learnerStartOverAllowed / learner_start_over_allowed), and it works only once the current attempt is completed, passed or failed. It returns 409 START_OVER_NOT_ALLOWED otherwise. Integrations should use /reset or new_attempt.
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.
An identical retry of an existing statement ID is idempotent. Reusing that ID with changed content returns 409 STATEMENT_CONFLICT; do not mutate the payload and retry under the same ID. Generic writes cannot create private native-form outcomes or overwrite one by reusing its ID.
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,
"truncated": false
}
}Generic statement list/detail and actor/activity analytics exclude private native-form outcomes. The Convex generic read window is bounded to 10,000 raw tenant rows and 8 MiB before private rows are filtered. When that window is exhausted, the list sets pagination.truncated: true: total and totalPages describe only visible rows in that window, not complete tenant totals. Increasing page does not turn that bounded window into a complete export; follow the sealed pagination.nextCursor (passed back as cursor, with the same filters) to read the rest. Actor/activity analytics read the workspace in bounded pages and are complete for a workspace of any size; if one request cannot finish (250,000 statements or about 20 seconds) it fails with 500 XAPI_ANALYTICS_INCOMPLETE unless allow_partial=true is sent, in which case the answer is marked incomplete: true. Treat failed analytics as unavailable, not zero activity.
Native LMS outcomes use a separate service integration, POST /api/v1/lms/form-outcomes, requiring a stored tenant internal_product key; ordinary public API keys and Clerk browser sessions cannot use it. The LMS checks /api/v1/lms/form-outcomes/authority before delivery. This adapter accepts completion/optional quiz score metadata, never answers or demographics, and does not establish learner mastery. Its customer-facing form reports suppress groups below the required privacy thresholds and do not appear in generic xAPI reads.
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.cssCustomer 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 Build → 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;
}
// For a claimed attempt, updates must retain this writer's runtime_id and attempt.
// Never substitute a different owner/attempt observed by a later GET.
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) {
const conflict = await response.json();
if (conflict.code !== 'VERSION_CONFLICT') {
throw new Error(`Session writer is no longer current: ${conflict.code}`);
}
continue; // Only a version conflict may 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
Retained SCORM interaction snapshot
GET /api/v1/sessions/{sessionId} now includes an additive interaction_report beside the unchanged player session and raw CMI aliases. It uses the same session token or tenant-authorized API key and the same no-store response; it does not require a new credential or change paid Skills access.
The report normalizes SCORM 1.2 student_response/time and SCORM 2004 learner_response/timestamp, orders observed items by their numeric CMI index, and preserves zero scores/weights and empty responses. Correct, incorrect (wrong in 1.2), neutral, unanticipated, numeric and unknown results remain distinct. Numeric results are not assumed to be scaled scores. Conflicting aliases yield null with an issue. Time/latency remain original strings because their meaning differs between SCORM versions. Nullable description carries retained question text, normally supplied by SCORM 2004, bounded to 4,096 characters. Recorded descriptions survive changes to current package metadata; missing or oversized values remain null. No authored question-bank discovery or inferred question text is performed.
This is the latest retained snapshot, not a complete question-attempt history. A resumed course can overwrite an interaction slot. Missing rows are not fabricated from _count. The report gives a session revision, unknown package_version and unknown sco_id; active-package reported_scorm_version does not prove the version used when the learner answered. Repeated IDs at different indices remain separate. evidence_classification is always unclassified: a correct answer, practice acknowledgement or course completion does not establish mastery or write skill/ROI events.
Bounds are 10,000 scanned own CMI keys, 200 observed items, index at most 100,000, and 4,096 characters per field. Oversized/malformed values are omitted with explicit issues and partial status; item/key limits never imply full coverage. declared_count: null means the count is unknown. empty means no projected rows in a known-format snapshot, while unavailable means no rows and unknown format. available describes the observed projection, not completeness of historical data. Answers remain sensitive learner information: do not publish them in logs, marketing or aggregate reports.
Optional hosted foreground activity
Learner launch producers may add learner_activity: "foreground_v1" alongside runtime_protocol: "exclusive_v1" for SCORM. Omission leaves legacy and author-preview behavior unchanged. Resume retains the opt-in. The hosted player observes trusted pointer, touch and keyboard input in visible accessible documents, including same-origin nested course frames. It does not infer input from CMI commits, load/focus, timers or polling. Inaccessible external frames and completed review runtimes are not observed. DOM isTrusted is input provenance, not hardware attestation.
The hosted producer sends POST /api/v1/sessions/{sessionId}/activity with a session launch Bearer token and { "runtime_id": "<current-owner-uuid>", "attempt": 1, "sequence": 1 }. Sequence increases per owner. The server checks tenant/session/recording owner/attempt and learner opt-in; it ignores old sequences and throttles accepted captures to one per minute. It accepts no learner identity or timestamp in the request. There is no offline replay/keepalive retry.
A successful capture snapshots server time and the exact session/package/learner/attempt into the existing signed webhook queue as session.foreground_activity (sessions/all subscriptions). Its data contains provenance: "foreground_v1", session_id, package_id, learner_id, attempt_number and millisecond captured_at. Delivery retry or a later attempt does not alter this snapshot. Receivers must verify the signature and their existing session binding, apply capture time monotonically, and never count delivery time or revive data whose retention deadline already elapsed. This event grants no completion, score or mastery.
Deploy the compatible Connect backend and LMS receiver before enabling the launch flag. This additive activity change requires its own signed transport and hosted-input acceptance; prior runtime progress/ownership evidence does not certify it.