HTTP API
HTTP API Reference
Use HTTP requests from your server to upload course files, import files from URLs, add externally hosted AICC courses, connect LTI tools, create learner sessions, and read saved results. For browser playback, pass a signed launch token to the JavaScript SDK.
Base URL: https://scormbox.com/api/v1. All request and response properties use snake_case. Send JSON unless uploading a binary part. Responses use JSON and are not cached.
What’s the use case?
Building an LMS or adding training to your app? SCORMBox handles playback and reporting for SCORM, AICC, LTI, H5P, xAPI and cmi5 through one integration. You don’t need to implement each standard separately.
Create or connect a course, create a learner session, and open it with the JavaScript SDK. The SDK sends saved result updates to your page through events. Your server can read learner results through this API. Across formats, normalized_details provides consistent completion, pass/fail, score and time fields, while runtime_details retains native data. Available values depend on what the course reports.
eLearning standards shouldn’t be this difficult. You build the LMS. We handle course playback and reporting.
Authentication
Open Manage keys, sign in with email, and create a project. Under Server API keys, create a key with the permissions your server needs. Copy both api_key and api_secret. The secret is shown once; only its hash is stored.
The project’s confirmed email owns its courses. An email in an upload request cannot change ownership. Learner emails identify saved progress within a course; they do not grant management access.
| Permission | Access |
|---|---|
courses:read | List courses, read preparation status and upload receipts, and read LTI connections. |
courses:write | Create courses and LTI connections, upload or abort files, change sharing/title, and delete courses. |
sessions:write | Create learner sessions and isolated previews with signed browser tokens. |
results:read | List attempt summaries and retrieve a learner’s saved result. |
The browser registration_key is public and restricted to your project’s website access rules. Exact websites are the default; only superadmins can enable wildcard subdomains or any-website access. See website access rules. Server credentials are separate: keep api_secret out of browser code, URLs and repositories. Server requests use HTTP Basic authentication over HTTPS, with api_key as the username and api_secret as the password. They do not require an Origin header or a sign-in cookie. Cross-origin browser access to this API is not enabled.
// Node.js, on your server.
const base_url = "https://scormbox.com/api/v1";
const authorization = "Basic " + Buffer.from(
process.env.SCORMBOX_API_KEY + ":" + process.env.SCORMBOX_API_SECRET
).toString("base64");
async function request(path, method = "GET", body) {
const response = await fetch(base_url + path, {
method,
headers: { Authorization: authorization, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
return result;
}Replacing a secret invalidates the old secret and its signed launch tokens immediately, while retaining the API key. Revoking a server key stops its server requests and signed launch tokens. Neither operation removes courses or saved results. Affected server-issued playback sessions are expired too; ordinary registration-key sessions remain independent. Browser registration rotation is independent. Disabling the whole project disables both credentials. A project can have 20 active server keys and 500 courses. Rate limits return 429.
Endpoint overview
Every route below is relative to https://scormbox.com/api/v1 and requires your server API key and secret. The same routes work from Ruby, Python, JavaScript, PHP, C#, Java or any HTTP client. For a ready course, use Create a session, then pass the returned token to the signed browser launch.
| Method | Route | Permission | Action |
|---|---|---|---|
POST | /courses | courses:write | Upload a file, import a package URL, add an AICC launch URL, or add an LTI activity. |
GET / POST | /lti/connections | courses:read / courses:write | List or register project-owned LTI tool connections. |
GET / PATCH | /lti/connections/{connection_id} | courses:read / courses:write | Read public settings or change a connection’s name/enabled state. |
PUT | /uploads/{upload_id}/parts/{part_number} | courses:write | Send one binary file part. |
POST | /uploads/{upload_id}/complete | courses:write | Finish uploading and prepare the course. |
DELETE | /uploads/{upload_id} | courses:write | Abort an unfinished upload. |
GET | /courses | courses:read | List courses. |
GET | /courses/{course_id} | courses:read | Read course details, upload receipts and preparation status. |
PATCH | /courses/{course_id} | courses:write | Change the title, learner-link sharing or PDF viewing requirements. |
POST / GET | /courses/{course_id}/exports | courses:write / courses:read | Create or list SCORM and AICC downloads. |
GET | /courses/{course_id}/exports/{export_id}/download | courses:read | Download the streamed ZIP. |
DELETE | /courses/{course_id}/exports/{export_id} | courses:write | Revoke a connected export. |
DELETE | /courses/{course_id} | courses:write | Remove a course and its saved results. |
POST | /courses/{course_id}/sessions | sessions:write | Select an attempt and issue a signed browser launch token. |
POST | /courses/{course_id}/previews | sessions:write | Create an isolated temporary preview and issue its browser token. |
GET | /courses/{course_id}/results | results:read | List results, or select one learner with email. |
GET | /courses/{course_id}/results/{attempt_id} | results:read | Read one attempt, optionally a saved generation. |
POST | /courses/{course_id}/results | results:read | Existing JSON-body lookup for one learner. |
Upload examples has six downloadable uploaders. Server session and result examples use the same credentials for learner launches and saved status. The HTTP API is server-only; give browser JavaScript a signed launch token instead of API credentials.
The previous /courses/{course_id}/scorm-exports routes remain aliases for /exports, including listing, download and revocation.
Errors
Error responses contain { error: "message" }. Unknown or camelCase request properties are rejected. Server credentials grant access only within their project, including file uploads and results.
| HTTP status | Meaning |
|---|---|
400 | Invalid JSON, properties, part size or option values; or HTTP outside localhost development. |
401 | Missing, invalid, replaced or revoked credentials; or a disabled project. |
403 | The key lacks the endpoint’s permission. |
404 | Course, upload or attempt does not exist in this project. |
409 | Upload busy/incomplete, idempotency mismatch, attempt conflict, or change incompatible with current course status. |
413 | Package or submitted JSON exceeds its limit. |
429 | Request, active-key or project-course limit reached. |
500 | Temporary service failure. Retry safe/idempotent operations with backoff. |
Never retry an entire course creation blindly without an idempotency key. For network errors, use a course status read to confirm receipts or completion before continuing. For 401, check the current key and secret; replacing a secret means updating your server configuration.
Courses
Upload a course package, video or PDF, import a file from a storage URL, add an AICC course hosted by its provider, or connect an LTI activity. Manage these sources in your project with the same course, session and result routes.
Create a course
POST /courses accepts four ways to provide content. All require a server API key with courses:write and return a course_id immediately. File uploads and URL imports prepare content in the background; direct AICC URLs and LTI activities are ready immediately.
| Content source | Properties | Next step |
|---|---|---|
| A course package, video or PDF already on S3 or another storage server. | fetch_url and optional canonical_url. | SCORMBox downloads it. See the URL example. |
| A local ZIP, H5P, PDF, MP4 or WebM file that your server will transfer. | filename and exact size in bytes. | Send the returned upload's file parts. See file uploads. |
| An AICC HACP course hosted by its provider. | format: "aicc", launch_url and optional canonical_url. | Content stays on the provider's server. See direct AICC URLs. |
| An activity from a registered LTI tool. | format: "lti" and connection_id, with an optional activity launch_url. | Ready immediately. See LTI activities. |
Choose one source per request. All accept optional title and shareable; these apply only when creating a new course. Ownership comes from your key's project. Course creation does not create a learner or attempt. Once ready, pass course_id to the JavaScript SDK, or create a learner session.
Create from an AICC URL
Send the provider's actual AICC HACP launch URL to POST /courses. This creates a ready course without downloading content, transferring file parts or unpacking a ZIP. The provider continues to host the course. An ordinary webpage does not become tracked AICC content by adding its URL.
| Property | Meaning |
|---|---|
format | Required: "aicc". |
launch_url | Required HTTPS URL of an AICC HACP course. SCORMBox preserves its existing URL parameters, replacing AICC_SID and AICC_URL with the active learner session's values at launch. |
canonical_url | Optional stable HTTPS course identity for reuse within this project. Defaults to the normalized launch URL. Keep secrets out of this identity. |
title | Optional course title, up to 200 characters. |
shareable | Optional boolean; defaults to false for SDK-only access. Set true to enable a learner link. |
{
"format": "aicc",
"launch_url": "https://provider.com/course/start",
"canonical_url": "https://provider.com/courses/staff-training",
"title": "Staff training",
"shareable": false
}// Server only. Uses request() from Authentication.
const course = await request("/courses", "POST", {
format: "aicc",
launch_url: "https://provider.com/course/start",
title: "Staff training",
shareable: false
});
console.log(course.course_id, course.status); // Ready immediately.A new course returns 201 with format: "aicc", status: "ready", source_type: "aicc_url", its launch_url, upload: null and zero preparation files. No preparation polling or upload completion request is needed. The course appears in My courses with the same preview, management and sharing tools as an uploaded course.
The default canonical identity removes AICC_SID, AICC_URL and the fragment, and sorts the query parameters. Other provider parameters, including signatures, remain part of the identity. The actual launch preserves the supplied query order and fragment. Provide canonical_url explicitly when you need a separate stable identity.
The same canonical identity and launch URL in the same project return the existing direct AICC course (200). Reuse retains its launch URL, title, sharing and learner progress; use PATCH /courses/{course_id} to change title or sharing. Changing launch_url under the same canonical identity returns 409; use a separate canonical identity for another course. A direct AICC source and a package source cannot share a canonical identity (409). An optional project-scoped Idempotency-Key makes retries of the same creation request return the same ready course.
Do not combine launch_url with fetch_url, filename or size. reprocess: true and reprocess: "force" are invalid for direct AICC URLs because SCORMBox has no package to download again.
Pass the returned course_id to the SDK AICC example, or create a signed session. The same learner identity, resume/new/reset attempts, events, saved results and reports apply. Omit the SCORM runtime option for AICC.
The existing HACP receiver accepts browser and provider-server callbacks for the active launch session, including resume data, completion, score, time and bookmarks. Results depend on what the provider reports, and course availability remains under the provider's control. If a provider blocks iframe embedding, use the player's Open course in new tab fallback to open the provider directly. SDK popup and new_window modes open the SCORMBox player in a separate window; the provider may still require that fallback. Learners should use the provider's Finish/Exit control to send its final report.
Add an LTI tool via HTTP
Configure an LTI provider from your server, then play its activities with the browser SDK. SCORMBox is the Tool Consumer for LTI 1.1 and the Platform for LTI 1.3.
1. Register the tool: POST /api/v1/lti/connections saves the provider settings and returns a connection_id in its id property. 2. Add an activity: POST /api/v1/courses with format: "lti" and that connection_id returns a ready course_id. 3. Play the course: pass course_id and the learner to the JavaScript SDK.
The complete Node.js example below makes the first two requests. Download the JavaScript example as add-lti-tool.mjs; it runs on your server with Node.js 20 or later and needs no packages to install. Create a server API key with courses:write first.
For LTI 1.3, get these URLs from your provider, set the environment variables, and run the file:
export SCORMBOX_API_KEY='YOUR_API_KEY'
export SCORMBOX_API_SECRET='YOUR_API_SECRET'
export LTI_VERSION='1.3'
export LTI_TOOL_NAME='Safety training provider'
export LTI_LAUNCH_URL='https://tool.example.com/launch'
export LTI_LOGIN_URL='https://tool.example.com/login'
export LTI_REDIRECT_URI='https://tool.example.com/oidc'
export LTI_JWKS_URL='https://tool.example.com/jwks'
node add-lti-tool.mjs// Save as add-lti-tool.mjs. Run on your server, never in the browser.
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running this example.`);
return value;
}
const authorization = "Basic " + Buffer.from(
requiredEnv("SCORMBOX_API_KEY") + ":" + requiredEnv("SCORMBOX_API_SECRET")
).toString("base64");
async function requestJson(path, body) {
const response = await fetch("https://scormbox.com/api/v1" + path, {
method: "POST",
headers: { Authorization: authorization, "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
return result;
}
const version = process.env.LTI_VERSION || "1.3";
if (!["1.1", "1.3"].includes(version)) throw new Error("Use LTI_VERSION 1.1 or 1.3.");
const connection_input = {
name: process.env.LTI_TOOL_NAME || "My LTI tool",
version,
launch_url: requiredEnv("LTI_LAUNCH_URL")
};
if (version === "1.1") {
connection_input.consumer_key = requiredEnv("LTI_CONSUMER_KEY");
connection_input.shared_secret = requiredEnv("LTI_SHARED_SECRET");
} else {
connection_input.login_url = requiredEnv("LTI_LOGIN_URL");
connection_input.redirect_uris = [requiredEnv("LTI_REDIRECT_URI")];
connection_input.jwks_url = requiredEnv("LTI_JWKS_URL");
}
const connection = await requestJson("/lti/connections", connection_input);
const course = await requestJson("/courses", {
format: "lti",
connection_id: connection.id,
title: connection_input.name,
shareable: false
});
console.log(JSON.stringify({
connection_id: connection.id,
course_id: course.course_id,
management_url: course.management_url,
...(version === "1.3" ? { platform_settings: {
issuer: connection.issuer,
client_id: connection.client_id,
deployment_id: connection.deployment_id,
auth_url: connection.auth_url,
token_url: connection.token_url,
jwks_url: connection.jwks_url
} } : {})
}, null, 2));For LTI 1.1, use the same file with these settings instead:
export SCORMBOX_API_KEY='YOUR_API_KEY'
export SCORMBOX_API_SECRET='YOUR_API_SECRET'
export LTI_VERSION='1.1'
export LTI_TOOL_NAME='Safety training provider'
export LTI_LAUNCH_URL='https://tool.example.com/launch'
export LTI_CONSUMER_KEY='PROVIDER_CONSUMER_KEY'
export LTI_SHARED_SECRET='PROVIDER_SHARED_SECRET'
node add-lti-tool.mjsFor LTI 1.3, copy the returned platform_settings into the provider’s registration before playing the course. This completes the provider’s side of the setup; creating a connection alone does not register SCORMBox with the provider. See the setting descriptions below.
Run the registration once per tool connection. Reuse its connection_id to add more activities through POST /courses. Set shareable: true when creating a course if you also want a learner link.
On your website, include the browser SDK and use the returned course ID:
<script src="https://scormbox.com/sdk.min.js"></script>
<div id="training" style="height: 700px"></div>
<script>
const player = ScormBoxPlayer.initialize("YOUR_BROWSER_REGISTRATION_KEY");
const launch = player.launchCourse({
el: "#training",
course_id: "COURSE_ID_FROM_SERVER",
user: { name: "John Doe", email: "john@example.com" }
});
launch.onEvent((type, result) => {
console.log(type, result.normalized_details);
});
launch.ready.catch(error => console.error(error.message));
</script>Use a browser registration key from the same project and register your website’s origin. For learner identity chosen by your LMS, use a signed launch token. Provider secrets and your server API secret stay on your server.
LTI tool connections
SCORMBox acts as an LTI 1.1 Tool Consumer or an LTI 1.3 Platform, launching activities hosted by an external provider. A project-owned connection can serve several courses. Verified owners can manage connections at LTI tools; server integrations use these routes with the same API key and secret as courses.
| Method | Route | Result |
|---|---|---|
GET | /lti/connections | { connections: [...] }; requires courses:read. |
POST | /lti/connections | Public connection metadata (201); requires courses:write. |
GET | /lti/connections/{connection_id} | Public metadata for a connection in this project; requires courses:read. |
PATCH | /lti/connections/{connection_id} | Accepts name and/or boolean enabled; requires courses:write. |
Connection names contain 1–100 characters. Both versions require an HTTPS launch_url and accept optional custom_parameters: an object of string values. Keep credentials out of URLs and custom parameters. Credentials and private signing keys are never returned in connection responses.
For LTI 1.1, obtain the provider’s consumer key and shared secret, then register them from your server:
// Server only. Uses request() from Authentication.
const legacyTool = await request("/lti/connections", "POST", {
name: "Practice tool 1.1",
version: "1.1",
launch_url: "https://tool.example/launch",
consumer_key: process.env.LTI_CONSUMER_KEY,
shared_secret: process.env.LTI_SHARED_SECRET
});SCORMBox signs launches using OAuth 1.0 HMAC-SHA1. Authenticated Basic Outcomes supports score replace/read/delete. The provider’s shared secret is encrypted at rest. Connection responses do not reveal either the consumer key or shared secret.
For LTI 1.3, use manual registration. Ask the provider for its OIDC login initiation URL, exact redirect URIs, launch URL and either a public JWKS URL or public JWKS JSON. Include deep_link_url if it supports Deep Linking 2.0:
const tool = await request("/lti/connections", "POST", {
name: "Practice tool 1.3",
version: "1.3",
launch_url: "https://tool.example/launch",
login_url: "https://tool.example/login",
redirect_uris: ["https://tool.example/launch"],
jwks_url: "https://tool.example/jwks",
deep_link_url: "https://tool.example/select"
});
// Register these public values with the provider:
console.log(tool.issuer, tool.client_id, tool.deployment_id);
console.log(tool.auth_url, tool.token_url, tool.jwks_url);Use exactly one of jwks_url or public_jwks: { keys: [...] }. The input jwks_url identifies the provider’s keys; the response provider_jwks_url retains that URL, while response jwks_url identifies SCORMBox’s platform keys. Copy these generated settings back to the provider:
| Response property | Provider registration setting |
|---|---|
issuer | Platform issuer: the content origin followed by /lti. |
client_id | Generated client ID for this connection. |
deployment_id | Generated deployment ID. |
auth_url | OIDC authentication endpoint. |
token_url | OAuth token endpoint for signed client assertions. |
jwks_url | Public platform signing keys. |
Launches use OIDC form-post with RS256 signatures. Supported Assignment and Grade Services (AGS) scopes are https://purl.imsglobal.org/spec/lti-ags/scope/score and https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly. SCORMBox does not offer roster access (NRPS), line-item creation/management or general gradebook management. Dynamic registration, LTI 1.1 Content Item selection and certification claims are outside this implementation.
Deep Linking is available to verified owners at LTI tools. Choose activities in tool opens the provider as an Instructor for activity selection. On return, choose an activity to fill the course form, then explicitly add the course. Ordinary course launches use the Learner role. The owner-cookie routes are POST /api/lti/connections/{connection_id}/deep-link with project_id, returning { start_url }, and GET /api/lti/deep-links/{selection_id}, returning { id, project_id, connection_id, items }. These are owner browser routes outside /api/v1; server API keys do not authorize Instructor selection.
Disabling a connection with { enabled: false } blocks launches and grade updates while retaining courses and saved results. Only name and enabled state can be patched. Register a new connection if the provider settings change.
Create from an LTI tool
After registration, send POST /courses with format: "lti" and the connection’s id as connection_id. The connection must be enabled and belong to the authenticated project.
| Property | Meaning |
|---|---|
format | Required: "lti". |
connection_id | Required ID returned by LTI connection registration. |
launch_url | Optional activity URL; defaults to the connection’s launch URL. Its origin must be authorized by the connection’s launch URL or redirect URIs. |
title | Optional course title, up to 200 characters; defaults to the connection name. |
custom_parameters | Optional object of string-valued activity parameters. |
passing_score | Optional numeric percentage from 0 through 100. Without a configured threshold, pass/fail stays unknown. |
shareable | Optional boolean; defaults to false for SDK-only access. |
const course = await request("/courses", "POST", {
format: "lti",
connection_id: tool.id,
launch_url: "https://tool.example/activities/practice",
title: "Practice activity",
custom_parameters: { activity: "practice" },
passing_score: 80,
shareable: false
});
console.log(course.course_id); // Ready immediately.
const session = await request("/courses/" + course.course_id + "/sessions", "POST", {
user: { name: "John Doe", email: "john@example.com" },
origin: "https://lms.example.com",
attempt_mode: "resume"
});
// Give only session.launch_token to this learner’s authorized browser.New LTI courses return 201, source_type: "lti", format: "lti11" or "lti13", status: "ready", connection_id, launch_url and upload: null. No file transfer, extraction or preparation polling is needed. An optional project-scoped Idempotency-Key reuses the same creation request (200); changed properties with that key return 409. Without one, each accepted request creates a course. Package uploads, archives and forced reprocess do not apply to LTI courses.
Use course_id with the SDK LTI example, or use the signed session above with the signed browser launch. management_url is for the verified owner; send learner_url to learners only when sharing is enabled. The owner UI creates the same project courses through POST /api/lti/courses with project_id and connection_id.
If the provider refuses iframe embedding, SDK popup or new_window display modes may help. Use the player’s provider-opening fallback if it still blocks the embedded frame.
The tool owns its internal bookmarks and work. Resume keeps stable learner/context/resource-link identities, but local new/reset cannot promise to reset the tool’s own activity. Reset archives local results and separates the new generation from earlier grade targets. Valid asynchronous grades can arrive after the player closes; read results again later when needed. Preview grades never become learner submissions.
AGS activityProgress: "Completed" reports completion. gradingProgress: "Failed" means the grading process failed, not that the learner failed. A final numeric grade plus configured passing_score can determine pass/fail; pending grades and an absent threshold keep success unknown. Basic Outcomes reports score only. Closing a window never proves completion or forces the provider to submit a grade.
Import from a URL
Send a downloadable course package, video or PDF URL to POST /courses. Your server does not need to download or transfer the file itself. Public HTTPS URLs and temporary signed S3/storage URLs are supported, including download endpoints without a .zip extension. The URL must remain accessible until SCORMBox finishes downloading.
| Property | Meaning |
|---|---|
fetch_url | Required public HTTPS URL returning ZIP, H5P, PDF, MP4 or WebM, up to 500 MB. |
canonical_url | Optional stable HTTPS course identity when signatures or download URLs change. It does not need to be downloadable. |
format | Optional "pdf" or "video" hint for media URLs without a recognizable filename. ZIP and H5P packages are detected automatically. |
minimum_total_seconds | PDF only: optional minimum viewing seconds for the entire document, integer 0–86,400. Defaults to 0 (disabled). |
minimum_page_seconds | PDF only: optional minimum viewing seconds for each page, integer 0–86,400. Defaults to 0 (disabled). |
reprocess | Omit or false to reuse the prepared course. true or "force" creates a fresh version from this URL. Refresh details. |
title | Optional title for a newly created course, up to 200 characters. |
shareable | Optional boolean for a newly created course; defaults to false for SDK-only access. |
export SCORMBOX_API_KEY='YOUR_API_KEY'
export SCORMBOX_API_SECRET='YOUR_API_SECRET'
curl --fail-with-body \
--user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: staff-training-v2' \
--data '{
"fetch_url": "https://your-storage.com/training-v2.zip?X-Amz-Signature=YOUR_SIGNATURE",
"canonical_url": "https://your-storage.com/training-v2.zip",
"title": "Staff training",
"shareable": false
}' \
https://scormbox.com/api/v1/coursesA new course returns 201, usually with status: "fetching" and upload: null. There are no file parts to send or upload completion endpoint to call. Poll GET /courses/{course_id} with courses:read permission: status advances through fetching and extracting to ready, or failed with an error. Preparation reports extracted-file progress. The same response fields and playback methods work for file uploads and URL imports.
// Uses base_url, authorization and request() from Authentication.
const response = await fetch(base_url + "/courses", {
method: "POST",
headers: {
Authorization: authorization,
"Content-Type": "application/json",
"Idempotency-Key": "staff-training-v2"
},
body: JSON.stringify({
fetch_url: "https://your-storage.com/training-v2.zip",
canonical_url: "https://your-storage.com/training-v2.zip",
title: "Staff training"
})
});
let course = await response.json();
if (!response.ok) throw new Error(course.error);
while (course.status !== "ready" && course.status !== "failed") {
await new Promise(resolve => setTimeout(resolve, 2000));
course = await request("/courses/" + encodeURIComponent(course.course_id));
}
if (course.status === "failed") throw new Error(course.error);
console.log("Ready to play:", course.course_id);URL imports use the same canonical normalization as the browser SDK. Recognized temporary signing parameters are removed from the identity, and supported S3 URL forms normalize to the same path. The original signed URL is used for downloading. Supply canonical_url for other signing schemes. The temporary download URL is discarded after downloading; keep secrets out of the canonical identity.
The same canonical identity in the same project returns the existing course (200), including courses first imported through the browser SDK. This applies with or without an Idempotency-Key. Reusing a course preserves its content, title, sharing, PDF requirements and all learner progress; use PATCH /courses/{course_id} to change its settings. Replacing the file at the same URL does not refresh an import automatically. Use reprocess: "force" to refresh that canonical identity, or supply a new versioned canonical URL. A failed import can also be retried with reprocessing.
An optional project-scoped Idempotency-Key also makes retries safe. For URL imports it binds the normalized canonical identity, title, sharing, format hint and any PDF requirements, so refreshed download signatures can reuse the same key. Changing those properties, or switching to file-upload mode with the same key, returns 409. A retry does not replace the original import's download source. Even when several keys reuse one course, every key retains its own request binding.
Refresh a package
POST /courses also accepts reprocess: "force" or true with fetch_url and optional canonical_url. Use this to download updated content under the same canonical identity. Omit it or pass false to reuse prepared content. File uploads and direct AICC URLs do not accept forced reprocessing.
export SCORMBOX_API_KEY='YOUR_API_KEY'
export SCORMBOX_API_SECRET='YOUR_API_SECRET'
curl --fail-with-body \
--user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: training-refresh-v3' \
--data '{
"fetch_url": "https://your-storage.com/training.zip?YOUR_FRESH_SIGNATURE",
"canonical_url": "https://your-storage.com/training.zip",
"reprocess": "force"
}' \
https://scormbox.com/api/v1/coursesA new refresh returns 201 and a new course_id, initially fetching. Poll its course status until ready, then create a session using that new ID. Course metadata includes content_revision and previous_course_id. Title overrides and sharing settings are inherited unless explicitly supplied; without an override, the new package supplies its title.
The canonical lookup switches only after the refreshed package is ready. Older course IDs, files, attempts and results remain available, and ongoing playback keeps its original files. A failed refresh leaves the previous version current. Learners start fresh on the new version; no progress is copied between content versions. Retained versions count toward the 500-course project limit.
Send an Idempotency-Key and reuse it for retries of the same refresh; a replay returns 200 and the same new course, including after preparation completes. true and "force" are equivalent. Use a new key for each deliberate refresh or retry after a failed package. Another refresh of the same canonical course while preparation is in progress returns 409. Without an idempotency key, every accepted forced request creates a version. Refreshes are limited to 30 per project per hour.
Server-issued browser tokens stay bound to their original course and cannot force a refresh. Refresh on your server and issue a new token after preparation succeeds.
Start an upload
For file uploads, POST /courses creates a course and starts a multipart upload. ZIP, H5P, PDF, MP4 and WebM files can be up to 500,000,000 bytes. This allows files above an individual HTTP request’s size limit. Preparation allows up to 2 GB extracted, 500 MB per file and 5,000 ZIP entries. Video and PDF uploads keep the original file without unzipping it. Use URL import instead when the file is already downloadable from your storage server.
| Property | Meaning |
|---|---|
filename | Required .zip, .h5p, .pdf, .mp4 or .webm filename, up to 200 characters. |
size | Required exact file size in bytes, as an integer. Minimum 22 bytes. |
minimum_total_seconds | PDF only: optional minimum viewing seconds for the entire document, integer 0–86,400. Defaults to 0 (disabled). |
minimum_page_seconds | PDF only: optional minimum viewing seconds for each page, integer 0–86,400. Defaults to 0 (disabled). |
title | Optional course title, up to 200 characters. Otherwise the package title is used after preparation. |
shareable | Optional boolean; defaults to false. Set true to enable a public learner link. |
const response = await fetch(base_url + "/courses", {
method: "POST",
headers: {
Authorization: authorization,
"Content-Type": "application/json",
"Idempotency-Key": "your-lms-content-version-id"
},
body: JSON.stringify({ filename: "training.zip", size: file_size, shareable: false })
});
const course = await response.json();
if (!response.ok) throw new Error(course.error);
console.log(course.course_id, course.upload.upload_id, course.upload.part_size);An optional Idempotency-Key prevents duplicate file-upload courses when you retry creation. It is scoped to the project and accepts 1–128 visible ASCII characters without spaces. The same key and upload properties return the existing course (200); a new course returns 201. Changed properties return 409. Once an upload starts, retries return the same upload ID and receipts. A temporary failure starting storage can be retried with the same key. Without this header, every file-upload request makes a new course. Use a new key for each content version.
Course responses include course_id, title, filename, format, source_type, status, error, canonical_url, content_revision, previous_course_id, shareable, management_url, learner_url, upload_limit, created_at, updated_at, preparation and upload. Direct AICC courses expose source_type: "aicc_url" and launch_url; they have format: "aicc" immediately and upload: null. Package sources use source_type: "package", with format null until inspection. Native media uses source_type: "file" and format: "pdf" or "video" after upload. management_url opens the owner’s email-protected library page; it is not a management capability to send to learners. learner_url is null until sharing is enabled. LTI activities expose source_type: "lti", connection_id, launch_url, format: "lti11" or "lti13", and upload: null. SDK-only courses can still be previewed and managed in My courses.
// Example upload field; the last part may be smaller.
{
upload_id: "returned-upload-id",
part_size: 16777216,
part_count: 3,
expected_size: 40000000,
completed: false,
parts: [{ part_number: 1, size: 16777216 }]
}Send file parts
PUT /uploads/{upload_id}/parts/{part_number} sends a binary chunk. Number parts from 1. Every part must have exactly the returned part_size, except the last, which contains the remaining bytes. Send Content-Length with the exact chunk length. Parts are 16 MiB; the API reads only one part at a time. Encode the upload ID when building its URL.
const response = await fetch(base_url + "/uploads/" +
encodeURIComponent(course.upload.upload_id) + "/parts/" + part_number, {
method: "PUT",
headers: {
Authorization: authorization,
"Content-Type": "application/octet-stream",
"Content-Length": String(bytes.length)
},
body: bytes
});
const receipt = await response.json();
if (!response.ok) throw new Error(receipt.error);
// { ok: true, part_number: 1 }Upload parts sequentially. Retrying a part replaces that part safely. GET /courses/{course_id} returns the saved part numbers; skip those parts when resuming an interrupted upload. Concurrent operations on an upload can return 409; wait briefly and retry. Unfinished uploads expire after an hour without activity. Delete an expired or aborted course and start a new upload.
Finish and check preparation
POST /uploads/{upload_id}/complete with {} validates all parts and queues package preparation. It returns 202 with { ok: true }. Completion retries are safe, including if storage finished before the first response reached your server. Preparation continues in the background.
await request("/uploads/" + encodeURIComponent(course.upload.upload_id) + "/complete", "POST", {});
let current;
for (;;) {
current = await request("/courses/" + course.course_id);
if (current.status === "ready") break;
if (current.status === "failed") throw new Error(current.error);
await new Promise(resolve => setTimeout(resolve, 2000));
}
console.log(current.course_id, current.format);Statuses are awaiting_upload, uploading, extracting, ready and failed. Existing courses imported through the SDK may initially be fetching. preparation.completed_files and preparation.total_files show extraction progress. A failed package includes error; it cannot be played until successfully prepared. DELETE /uploads/{upload_id} aborts an unfinished upload and releases its parts; the course remains with a failed status. Delete the course to remove its record.
Launch an uploaded course
Once preparation is complete, pass course_id to the JavaScript SDK, or create a signed session for a learner chosen by your LMS. Direct AICC URLs and LTI activities are ready immediately and use the same flow.
HTML5 courses
Use the standard upload or URL-import endpoints for a ZIP containing index.html and its assets. Omit format: HTML5 is detected automatically when no recognized course descriptor is present. A root index.html takes priority; otherwise include one unambiguous entry inside a folder.
const course = await request('/courses', 'POST', {
fetch_url: 'https://your-download-server.com/html5-lesson.zip',
canonical_url: 'https://your-download-server.com/html5-lesson.zip',
title: 'HTML5 lesson'
});After preparation, the course returns format: "html5". Create a learner session using the existing route, then play it with the JavaScript SDK. Course scripts can call ScormBox.setStatus(), setScore(), setData() and getData() without initialization. See the four-method HTML5 course API and download a complete example ZIP.
Saved results use the normal result endpoints: completion, success, score and visible seconds are in normalized_details; custom course data is in runtime_details.data.html5.data. Status and data resume with the attempt. Existing new/reset options and connected SCORM/AICC exports also apply. The course code contains no API secret or registration key.
Video and PDF courses
Upload a PDF, MP4 or WebM using the same file-part endpoints. For a remote file, pass fetch_url and optional canonical_url as usual. Add format: "pdf" or format: "video" when the URL has no recognizable filename. Media files are prepared without ZIP extraction and return source_type: "file", format: "pdf" or "video", and runtime: null in learner sessions/results. Video must use a browser-supported codec; SCORMBox does not transcode it.
Upload a video or PDF
Start with POST /courses, send the file through the returned upload's file-part endpoints, then complete the upload. Use the exact byte size of your file; these sizes are examples.
{
"filename": "training.mp4",
"size": 12000000,
"title": "Safety video",
"shareable": true
}{
"filename": "handbook.pdf",
"size": 2400000,
"title": "Staff handbook",
"shareable": true,
"minimum_total_seconds": 90,
"minimum_page_seconds": 5
}The six downloadable uploaders also accept PDF, MP4 and WebM paths in place of ZIP paths. To set PDF requirements with an uploader, use PATCH afterward, or add the optional fields to its initial creation request.
Import a video or PDF URL
Send the file URL to POST /courses. SCORMBox downloads it; no file-part or upload-completion requests are needed. Poll GET /courses/{course_id} until ready before creating a learner session. These examples use request() from Authentication.
const video = await request("/courses", "POST", {
fetch_url: "https://your-storage.com/training.mp4",
canonical_url: "https://your-storage.com/courses/safety-video",
title: "Safety video",
shareable: true
});
const pdf = await request("/courses", "POST", {
fetch_url: "https://your-storage.com/download?id=handbook",
canonical_url: "https://your-storage.com/courses/staff-handbook",
format: "pdf",
minimum_total_seconds: 90,
minimum_page_seconds: 5
});
console.log(video.course_id, pdf.course_id);For a video URL without a filename, set format: "video"; MP4 or WebM is detected from the downloaded file. Keep any signed download URL valid until preparation finishes. Use Idempotency-Key for safe retries and reprocess: "force" to import an updated file as a new revision.
Viewing requirements and results
For PDFs, POST /courses accepts minimum_total_seconds and minimum_page_seconds when creating a file upload or URL import. Use PATCH /courses/{course_id} to change them later. Both are optional integers from 0 through 86,400; zero disables that rule. The page minimum applies to every page. Learners must view every page and meet both enabled minimums. Unfinished attempts use current rules; completed attempts retain their earned completion. Reports show total viewing time and time per page, with a detailed PDF CSV on the private course report. Initial requirements are saved before preparation starts. Creation retries preserve later edits; reusing an existing canonical course preserves its current settings. Use PATCH to update that course, or include requirements in a forced import for a new revision. These two settings are not accepted for videos or other course types.
Video completes after at least 95% unique playback and reaching the end. Seeking to the end alone does not complete it. Viewing time counts visible playback or rendered PDF pages in a visible tab. It excludes pauses, loading and hidden tabs, and divides time between simultaneously visible PDF pages. It is browser-reported viewing time, not proof of attention.
Results keep normalized_details.seconds for total viewing time and add normalized_details.media for progress. PDF page times and video played ranges are under runtime_details.data.media. Media completion reports completed or incomplete, with success: "unknown" and score: null.
// Run on your server with its usual Basic API-key/secret authorization.
await fetch(`https://scormbox.com/api/v1/courses/${course_id}`, {
method: "PATCH",
headers: { Authorization: auth, "Content-Type": "application/json" },
body: JSON.stringify({ minimum_total_seconds: 90, minimum_page_seconds: 5 })
});Download for an LMS
POST /courses/{course_id}/exports creates a saved LMS-importable ZIP for a ready course. Set format: "scorm" (the default) or format: "aicc". All supported course sources can create a connected export: SCORM 1.2/2004, AICC packages and URLs, H5P, TinCan/xAPI, cmi5, LTI, video, PDF and HTML5.
For SCORM, use mode: "standalone" for video/PDF, "original" for the unchanged original SCORM package, or "connected" for any supported course. Omit mode to keep the existing defaults: standalone media, original SCORM, or connected for other sources. Generated SCORM downloads accept scorm_version: "1.2" (default) or "2004". Original downloads keep the source version; they do not convert it.
For AICC, mode defaults to "connected" and only connected mode is supported. Omit scorm_version. The ZIP contains AICC import files pointing to a hosted launch wrapper; the course content stays hosted, including video and PDF. The destination LMS must provide a public HTTPS HACP endpoint reachable by SCORMBox's servers. HTTP and private-network endpoints are unsupported. HACP requests run server to server, so the LMS endpoint does not need browser CORS support.
- Standalone: includes the media, viewer and current PDF rules. It works independently of SCORMBox after download. Per-page times and position resume through LMS suspend data. Detailed documents can exceed SCORM 1.2's 4,096-byte saved-state limit; use SCORM 2004 for its 64,000-byte limit. The viewer explains a limit error instead of dropping page times. - Original: downloads the stored SCORM archive unchanged. - Connected SCORM: a small SCORM wrapper opens the hosted course and reports normalized completion, pass/fail, score and session time to the destination LMS. - Connected AICC: an AICC wrapper opens the hosted course and reports normalized completion, pass/fail, score, session time and resume state using HACP GetParam, PutParam and ExitAU. Provider grades can still be forwarded after the learner’s browser closes.
Connected exports require the course to remain available on SCORMBox and, for external courses, its provider. Provider login and framing restrictions still apply. Native video completion and PDF viewing requirements continue to apply to hosted playback. The destination LMS receives each playback session's time contribution, rather than the full lifetime total again.
The response includes export_id, course_id, format, mode, scorm_version, created_at and revoked_at. format is "scorm" or "aicc"; AICC exports have scorm_version: null. GET /courses/{course_id}/exports lists both formats. GET /courses/{course_id}/exports/{export_id}/download downloads the same record again without creating a new capability. DELETE /courses/{course_id}/exports/{export_id} revokes a connected export and its active sessions while preserving learner results. Already downloaded standalone and original files cannot be revoked.
AICC export metadata also includes delivery: {pending, waiting_for_provider, failed, last_error, last_delivered_at}. The first three properties are counts; last_error and last_delivered_at are a string or null. The management page summarizes pending deliveries, sessions waiting for a provider grade, delivery failures and the latest successful delivery time. It does not display callback endpoints or session credentials.
AICC delivery survives browser close and ordinary session cleanup. Failed HACP posts retry with increasing delays capped at one hour; scheduled recovery runs every five minutes. Closing an LTI-backed AICC player sends its current PutParam and immediately ends browser write access, while deferring ExitAU until the final provider grade has been delivered. The receiving LMS must keep that AICC session valid and accept delayed posts; its own session expiry can still reject delivery. Revoking the export, deleting the course, resetting the attempt or superseding its launch stops the old delivery queue.
Connected downloads contain only course-scoped launch authority, never management links or API secrets. LMS learner IDs may be opaque; they are isolated by export and LMS namespace rather than treated as verified email addresses. The LMS's saved attempt resumes its original hosted revision. A fresh LMS attempt gets a separate hosted attempt and follows the current published canonical revision, if one exists. Connected SCORM can read late AICC/LTI reports when reopened; it cannot update the destination LMS while the outer SCORM browser is closed. Up to 64 website origins can be active concurrently for one course.
The website exposes the same options under Download for an LMS in each course's private management page. Private routes /api/manage/{capability}/exports and /api/library/{id}/exports use the same methods, export IDs and /download suffix. The previous scorm-exports routes remain backward-compatible aliases for all export operations.
# Keep both credentials on your server. Set the ready course ID first.
export SCORMBOX_API_KEY="your_api_key"
export SCORMBOX_API_SECRET="your_api_secret"
export SCORMBOX_COURSE_ID="your_course_id"
# Create a connected SCORM 2004 export. Save the returned export_id.
curl --fail-with-body --user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--header 'Content-Type: application/json' \
--data '{"format":"scorm","mode":"connected","scorm_version":"2004"}' \
"https://scormbox.com/api/v1/courses/$SCORMBOX_COURSE_ID/exports"
# Or create an AICC export. Import its ZIP into an LMS with public HTTPS HACP.
curl --fail-with-body --user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--header 'Content-Type: application/json' \
--data '{"format":"aicc"}' \
"https://scormbox.com/api/v1/courses/$SCORMBOX_COURSE_ID/exports"
# Use the export_id returned by the format you chose.
export SCORMBOX_EXPORT_ID="export_id_from_the_response"
curl --fail-with-body --user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--output course.zip \
"https://scormbox.com/api/v1/courses/$SCORMBOX_COURSE_ID/exports/$SCORMBOX_EXPORT_ID/download"Sessions
Choose the learner and attempt on your server, then hand the signed token to the JavaScript SDK.
Create a preview
POST /courses/{course_id}/previews creates an isolated temporary course preview. Requires sessions:write, a ready course in the key's project, and a concrete allowed website origin. Check the current administrator's or author's permission on your server before calling it. No learner identity or attempt is accepted.
curl --fail-with-body --silent --show-error \
--user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--header 'Content-Type: application/json' \
--data '{"origin":"https://lms.example.com"}' \
"https://scormbox.com/api/v1/courses/$SCORMBOX_COURSE_ID/previews"A successful response is 201 with preview: true, course_id, session_id, preview_token, expires_at, course metadata and an empty initial_result. expires_at is the token's Unix expiry time, five minutes after creation. Each call creates a separate preview. The optional runtime property follows the same SCORM playback selection rules as learner sessions; omit it for other formats.
Give the browser only preview_token, then call ScormBoxPlayer.initialize({ preview_token }).launchCourse({ el: "#training" }). The token binds the course, project, current server credential, temporary session and one concrete origin. Wildcards, paths and learner/attempt fields are rejected. It cannot launch or read a learner attempt, switch courses, read another preview or use another website. Server-secret replacement, key revocation, project disablement and removal of website access invalidate preview access. The open session lasts at most 24 hours; closing it ends its authority.
Preview results contain preview: true, has_attempt: false, attempt_id: null and normalized_details. Inspect them through the SDK launch handle. SCORM results are read live while the preview is open; other supported formats use temporary session results. HTML5 automatically tracks preview viewing time. No learner results or enrollment time are recorded, and ordinary learner result endpoints exclude previews. See the SDK preview lifecycle.
Create a session
POST /courses/{course_id}/sessions selects the learner's attempt and returns a signed launch token. The course must be ready and belong to the key's project. Enable sessions:write when creating the server key; an existing key without that permission cannot call this route. Use your LMS's authenticated learner and a concrete website origin allowed by the same SDK project’s website access rules.
| Property | Meaning |
|---|---|
user.name | Required learner name selected by your server. |
user.id or user.email | Supply either a case-sensitive student ID or an email address. Email is trimmed and lowercased. Do not combine the fields. |
origin | Required concrete allowed origin where the browser SDK will run, such as https://lms.example.com. Include any non-default port; omit page paths. Never send a wildcard or *, even when the project allows broad website access. |
attempt_mode | Optional resume (default), new or reset. |
attempt_id | Optional existing ID for resume; required for reset. new with an ID is rejected. |
Resume selects the latest attempt for the playback selection, or the existing attempt_id you supply. Keep the returned attempt_id to reopen that exact attempt. New creates another attempt and keeps earlier results. Reset keeps its ID, archives the previous generation, clears that attempt’s progress and increments generation.
These actions happen once on this server route. A signed browser launch can only resume the selected attempt; it cannot change the learner, course, origin or generation, or request a new/reset attempt.
Only superadmins can enable broad website access. A wildcard such as https://*.example.com:8443 allows nested subdomains on that exact scheme and port, excluding the apex unless it is also registered exactly. Any-website access (*) allows HTTPS websites and HTTP localhost and 127.0.0.1 development addresses. A session still binds the concrete origin above: a token issued for https://tenant-a.example.com cannot be replayed on https://tenant-b.example.com, even when both match the project’s rules. Removing a website rule expires sessions whose concrete website is no longer allowed; retained matches continue. See website access rules.
# Keep these credentials on your server.
export SCORMBOX_API_KEY="YOUR_SERVER_API_KEY"
export SCORMBOX_API_SECRET="YOUR_SERVER_API_SECRET"
export SCORMBOX_COURSE_ID="COURSE_ID_FROM_UPLOAD"
# Use one stable key for retries of this particular launch request.
curl --fail-with-body --silent --show-error \
--user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: your-lms-launch-request-123' \
--data '{"user":{"name":"John Doe","email":"john@example.com"},"origin":"https://lms.example.com","attempt_mode":"resume"}' \
"https://scormbox.com/api/v1/courses/$SCORMBOX_COURSE_ID/sessions"You can replace the example's user with {"name":"John Doe","id":"student-123"} to play without an email address. Student IDs are strings of 1–200 UTF-8 bytes, trimmed but case-sensitive, and scoped to the SDK project. The same ID resumes the same learner's progress. An ID that looks like an email remains a separate identity from user.email. Responses return user.id and user.email: null; signed browser tokens bind that exact identity. IDs do not verify email or grant access to My courses or management.
SaaS tenant isolation: learner IDs and emails belong to the project, not the calling website. If tenants share a project, use stable tenant-prefixed IDs such as tenant-acme:student-123 and tenant-beta:student-123; reuse the full ID for session creation and result queries. The same email across tenant websites remains one learner identity in that project. Authenticate the tenant and learner on your server, choose their ID there, and bind the signed session to that tenant’s concrete allowed origin. Website access rules alone do not authenticate learners or partition their identities.
An optional Idempotency-Key makes retries reuse the same launch channel without repeating new/reset. Keep the body unchanged and use a new key for a separate launch request. A changed body with the same key returns 409. A new session returns 201; an active-session replay returns 200 with a freshly issued token. If the original playback session has expired or been superseded, replay returns 410 without changing progress. Use a new key to request a separate session. Retry records remain until the course or server key is deleted, even after runtime-session cleanup.
The response includes course_id, title, format, canonical_url, session_id, attempt_id, generation, initial_result, embed_url, launch_url, launch_token and expires_at. initial_result contains saved normalized and activity details before playback. embed_url and launch_url are learner-session capabilities, never management links. launch_token expires after five minutes; expires_at is the expiry time in Unix epoch seconds. Give the browser only the scoped token it needs, as shown below.
Signed browser launch
Your LMS server authenticates with its API key and secret. SCORMBox issues a signed token for the chosen course, learner, attempt generation and concrete allowed origin. The SDK uses that token to launch and read the bound result. There is no shared secret to hash or sign in browser code.
Node.js handler in your LMS
This complete handler uses Node.js 20+ built-ins. Install it on your existing LMS server at /training/launch. Call it only after your LMS has authenticated the user and checked that they may access this course; pass that trusted identity as learner. Do not use a name or email submitted by the browser as authentication. Your LMS should also apply its normal CSRF protection. The handler additionally checks the request origin against your fixed LMS origin.
// Server only: launch-handler.mjs. No npm packages needed.
const base_url = "https://scormbox.com/api/v1";
const origin = process.env.LMS_ORIGIN; // e.g. https://lms.example.com
const course_id = process.env.SCORMBOX_COURSE_ID;
const api_key = process.env.SCORMBOX_API_KEY;
const api_secret = process.env.SCORMBOX_API_SECRET;
if (!origin || !course_id || !api_key || !api_secret) {
throw new Error("Set LMS_ORIGIN, SCORMBOX_COURSE_ID, SCORMBOX_API_KEY and SCORMBOX_API_SECRET.");
}
const authorization = "Basic " + Buffer.from(api_key + ":" + api_secret).toString("base64");
export async function handleTrainingLaunch(request, response, learner) {
const send = (status, body) => {
response.writeHead(status, {
"Content-Type": "application/json",
"Cache-Control": "no-store"
});
response.end(JSON.stringify(body));
};
if (request.method !== "POST") return send(405, { error: "Use POST." });
if (request.headers.origin !== origin) return send(403, { error: "Website not allowed." });
if (!learner || typeof learner.name !== "string" || typeof learner.email !== "string") {
return send(401, { error: "Sign in to your LMS first." });
}
try {
const upstream = await fetch(base_url + "/courses/" +
encodeURIComponent(course_id) + "/sessions", {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(20000),
headers: { Authorization: authorization, "Content-Type": "application/json" },
body: JSON.stringify({
user: { name: learner.name, email: learner.email },
origin,
attempt_mode: "resume"
})
});
const session = await upstream.json();
if (!upstream.ok) return send(502, { error: "Unable to prepare your course." });
// Never return API credentials or the management URL.
send(200, { launch_token: session.launch_token });
} catch {
send(502, { error: "Unable to prepare your course. Please try again." });
}
}
// In your existing authenticated/authorized POST route:
// await handleTrainingLaunch(request, response, signed_in_learner);The server uses the same API credentials as the uploaders. Add these course/origin settings on that server. The concrete origin must also be allowed by your SCORMBox SDK project’s website access rules.
export SCORMBOX_COURSE_ID="COURSE_ID_FROM_UPLOAD"
export LMS_ORIGIN="https://lms.example.com"Browser page on the registered LMS origin
The browser asks your LMS for the token, then supplies only display settings. The course, learner and attempt come from the signed authorization.
<script src="https://scormbox.com/sdk.min.js"></script>
<div id="training"></div>
<div id="results"></div>
<script type="module">
const response = await fetch("/training/launch", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}"
});
const session = await response.json();
if (!response.ok) throw new Error(session.error);
const player = ScormBoxPlayer.initialize({ launch_token: session.launch_token });
const launch = player.launchCourse({ el: "#training", display_mode: "iframe" });
launch.onEvent((type, result) => {
if (["state", "ready", "progress", "closed"].includes(type)) {
console.log(result.normalized_details, result.runtime_details);
}
});
launch.ready.catch(error => console.error(error.message));
// The signed learner and course also apply to result lookups and reports.
const saved = await player.getResult({});
console.log(saved.normalized_details);
const report = player.showReport({ el: "#results" });
report.ready.catch(error => console.error(error.message));
</script>The same token works with popup, new_window and overlay display modes. Button modes must open before the initial token expires; browser popups need a direct user click. The first SDK launch uses the issued session; reopening resumes the bound attempt with a fresh session. Changing identity or playback options, fetching another ZIP or requesting new/reset with a signed token is rejected. A URL query cannot override the signed playback options. Issue another token on your server to authorize a different attempt or learner.
Tokens stop working after server-secret replacement, server-key revocation, project disablement, removal of access to the bound origin or reset of that attempt generation. Server-secret replacement and key revocation also expire playback sessions issued by that server credential. Ordinary registration-key sessions remain independent. Public browser registration keys remain supported separately; use signed launches when your server should choose the authorized learner and course.
Results
Read saved progress without launching or changing playback.
Read learner results
GET /courses/{course_id}/results?page=0 returns a page of up to 50 current attempt summaries with course_id, results, page, total and has_more. Each summary has attempt_id, attempt_number, generation, user, completion, success, score, seconds, created_at, updated_at and normalized_details. Summaries do not include raw activity data. Multiple attempts for a learner appear separately.
GET /courses/{course_id}/results?user_id=student-123 retrieves a learner identified by user.id. Use URL encoding. The existing ?email= selector remains available for email identities; do not combine the selectors. All attempt and historical-generation options below work with either identity type. POST /courses/{course_id}/results also accepts {"user":{"id":"student-123"}}.
GET /courses/{course_id}/results?email=john%40example.com retrieves one learner’s saved result. Optional attempt_id selects an existing attempt; otherwise the latest attempt for the playback selection is used. Retain the returned attempt_id to retrieve that exact attempt. Optional generation reads an archived snapshot before a reset and requires an attempt_id. Use URL encoding for learner emails and IDs.
# These settings are used only on your server.
export SCORMBOX_API_KEY="YOUR_SERVER_API_KEY"
export SCORMBOX_API_SECRET="YOUR_SERVER_API_SECRET"
export SCORMBOX_COURSE_ID="COURSE_ID_FROM_UPLOAD"
curl --fail-with-body --silent --show-error \
--user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
--get --data-urlencode 'email=john@example.com' \
"https://scormbox.com/api/v1/courses/$SCORMBOX_COURSE_ID/results"
# Add these options to the same GET command to select a saved generation:
# --data-urlencode 'attempt_id=YOUR_ATTEMPT_ID' --data-urlencode 'generation=1'GET /courses/{course_id}/results/{attempt_id} retrieves one attempt directly, without an email query. Add ?generation=1 for an archived generation. The ID must belong to this project and course. Without a generation, it returns the current saved result.
export SCORMBOX_ATTEMPT_ID="YOUR_ATTEMPT_ID"
curl --fail-with-body --silent --show-error \
--user "$SCORMBOX_API_KEY:$SCORMBOX_API_SECRET" \
"https://scormbox.com/api/v1/courses/$SCORMBOX_COURSE_ID/results/$SCORMBOX_ATTEMPT_ID"The existing POST /courses/{course_id}/results JSON-body lookup is also supported. Send user: { id } or user: { email }, optionally attempt_id and generation with the same selection rules.
const result = await request("/courses/" + course_id + "/results?" +
new URLSearchParams({ email: "john@example.com" }));
console.log(result.has_attempt, result.attempt_id, result.generation);
console.log(result.normalized_details, result.runtime_details);Detailed results use the same shape as SDK results: common course/attempt/user properties, normalized_details for completion, success, percentage score, seconds and modules, and runtime_details for saved activity data. runtime_details.data preserves the course’s original data format. Reset generations and histories belong to their attempt. xAPI statement samples are bounded to the latest 20 statements and 256 KB. Completion and success are independent; missing scores are null. A learner with no attempt gets has_attempt: false and empty progress. Reading results never launches, creates, resets or supersedes an attempt. See SDK results for result definitions.
Code Examples
Download an uploader or choose a language for session and result requests. These examples use the same HTTP API endpoints.
Upload examples
Download a standalone uploader for your server. For learner launches and status reads in the same languages, see Server session and result examples. Each example uploads one part at a time, resumes saved parts on retry, and waits for preparation. They use HTTPS Basic authentication and bounded retries with backoff, without loading the whole file into memory. All six accept course packages, H5P, PDF, MP4 and WebM files.
In Manage keys, create a Server API key with courses:write and courses:read permissions. Copy its API key and secret for the environment setup below. These are separate from the browser registration key.
| Language | Download | Runtime requirements |
|---|---|---|
| Ruby | Download Ruby | Ruby 3.1+ with standard-library Net::HTTP and JSON. No gems required. |
| Python | Download Python | Python 3.9+ with standard-library urllib. No pip packages required. |
| JavaScript (plain ES6) | Download JavaScript | Node.js 20+. No npm packages required. |
| PHP | Download PHP | PHP 8+ with HTTP streams, HTTPS/OpenSSL and allow_url_fopen enabled. No Composer packages required. |
| C# | Download C# | .NET 10+ SDK with file-based app support. No project scaffolding or NuGet packages required. |
| Java | Download Java | Java 17+ JDK with built-in HttpClient. No Maven or Gradle packages required. |
Set up the environment for any language
Run these commands in a macOS or Linux terminal on your server or development machine. Replace YOUR_SERVER_API_KEY and YOUR_SERVER_API_SECRET with your server credentials. The variables apply to commands run in this terminal session. Keep the key and secret on your server.
# Required: use the server credentials from your SDK project.
export SCORMBOX_API_KEY="YOUR_SERVER_API_KEY"
export SCORMBOX_API_SECRET="YOUR_SERVER_API_SECRET"
# Required: choose a unique key for this course version.
# Keep it unchanged when retrying this upload.
export SCORMBOX_IDEMPOTENCY_KEY="team-training-v1"
# Optional: set a title and choose whether to enable learner sharing.
export SCORMBOX_TITLE="Team training"
export SCORMBOX_SHAREABLE="false"| Environment variable | What to set |
|---|---|
SCORMBOX_API_KEY | Required server API key from your SDK project. |
SCORMBOX_API_SECRET | Required secret shown when you create that server key. |
SCORMBOX_IDEMPOTENCY_KEY | Required unique identifier for this upload, such as team-training-v1. Reuse it for retries of the same package and upload options. Use a new value for another course or content version. |
SCORMBOX_TITLE | Optional title. Leave unset to use the package's title. |
SCORMBOX_SHAREABLE | Optional. Set to true to create a learner sharing link; otherwise the course is available through your SDK and owner preview. Defaults to false. |
SCORMBOX_API_BASE_URL | Optional API base URL. Defaults to https://scormbox.com/api/v1; leave unset when using SCORMBox.com. |
When retrying, keep the package, title and sharing setting unchanged; changed upload properties with the same idempotency key return 409. Run the same command again after an interrupted upload to resume its saved parts. The examples retry temporary network and API failures and poll preparation for up to 20 minutes. If preparation is still running after that, check the returned course_id before retrying.
Each example prints progress, then JSON with course_id, format, management_url and learner_url. Use course_id with the browser SDK to launch the course. learner_url is null when sharing is off.
JavaScript
Download JavaScript uploaderView JavaScript uploader
Use Node.js 20 or later. The uploader needs no npm packages. In Manage keys, create a Server API key with both courses:write and courses:read permissions. Copy its API key and secret; these are separate from the browser registration key.
Download and run the uploader
Run the following in a macOS or Linux terminal on your server or development machine. Replace YOUR_SERVER_API_KEY, YOUR_SERVER_API_SECRET and /path/to/course.zip with your values. The download command saves the script as scormbox-upload.mjs in your current folder.
# Check that Node.js is installed (version 20 or later).
node --version
# Download the standalone uploader into the current folder.
curl --fail --location "https://scormbox.com/server-upload-example.mjs" \
--output scormbox-upload.mjs
# Required: use the server credentials from your SDK project.
export SCORMBOX_API_KEY="YOUR_SERVER_API_KEY"
export SCORMBOX_API_SECRET="YOUR_SERVER_API_SECRET"
# Required: choose a unique key for this course version.
# Keep it unchanged when retrying this upload.
export SCORMBOX_IDEMPOTENCY_KEY="team-training-v1"
# Optional: set a title and choose whether to enable learner sharing.
export SCORMBOX_TITLE="Team training"
export SCORMBOX_SHAREABLE="false"
# Upload your local package and wait until it is ready to play.
node ./scormbox-upload.mjs "/path/to/course.zip"The variables apply to commands run in this terminal session. Keep the key and secret on your server. The browser SDK uses the browser registration key and the returned course_id.
The shared environment settings apply to this example too. To enable sharing during this upload, change SCORMBOX_SHAREABLE to true before running the script. When retrying, keep the package, title and sharing setting unchanged; changed upload properties with the same idempotency key return 409.
The script prints upload and preparation progress, then JSON containing course_id, format, management_url and learner_url. learner_url is null when sharing is off. Use course_id to launch the course with the SDK. It uploads one part at a time, resumes saved parts on retry, and waits for preparation without reading the whole package into memory.
You can also download the uploader directly.
Ruby
Download Ruby uploaderView Ruby uploader
After setting the environment above, run these commands. Replace /path/to/course.zip with your local package path.
# Check that Ruby is installed (version 3.1 or later).
ruby --version
# Download the standalone uploader into the current folder.
curl --fail --location "https://scormbox.com/server-upload-example.rb" \
--output scormbox-upload.rb
# Upload your local package and wait until it is ready to play.
ruby ./scormbox-upload.rb "/path/to/course.zip"Python
Download Python uploaderView Python uploader
Use the same environment settings and your local package path.
# Check that Python is installed (version 3.9 or later).
python3 --version
# Download the standalone uploader into the current folder.
curl --fail --location "https://scormbox.com/server-upload-example.py" \
--output scormbox-upload.py
# Upload your local package and wait until it is ready to play.
python3 ./scormbox-upload.py "/path/to/course.zip"PHP
Download PHP uploaderView PHP uploader
Use the same environment settings and your local package path. The uploader uses PHP's built-in HTTP streams; enable allow_url_fopen and HTTPS/OpenSSL in your PHP configuration.
# Check that PHP is installed (version 8 or later).
php --version
# Download the standalone uploader into the current folder.
curl --fail --location "https://scormbox.com/server-upload-example.php" \
--output scormbox-upload.php
# Upload your local package and wait until it is ready to play.
php ./scormbox-upload.php "/path/to/course.zip"C#
Download C# uploaderView C# uploader
Install the .NET 10+ SDK, then use the same environment settings and your local package path. Run the downloaded .cs file directly; the SDK handles the file-based app.
# Check that the .NET SDK is installed (version 10 or later).
dotnet --version
# Download the standalone uploader into the current folder.
curl --fail --location "https://scormbox.com/server-upload-example.cs" \
--output scormbox-upload.cs
# Upload your local package and wait until it is ready to play.
dotnet run --file ./scormbox-upload.cs -- "/path/to/course.zip"Java
Download Java uploaderView Java uploader
Install the Java 17+ JDK, then use the same environment settings and your local package path. Run the downloaded .java source file directly.
# Check that Java is installed (version 17 or later).
java --version
# Download the standalone uploader into the current folder.
curl --fail --location "https://scormbox.com/server-upload-example.java" \
--output scormbox-upload.java
# Upload your local package and wait until it is ready to play.
java ./scormbox-upload.java "/path/to/course.zip"Use another language
Ask Google AI to adapt the example and specify the language you want. The prompt uses the public JavaScript uploader and this API guide to request runnable code and setup instructions.
Server session and result examples
Use the same server key and secret as the uploaders, with sessions:write and results:read enabled. These small examples create a resumable signed session, then read that learner's current saved result over HTTP. Each uses only the language's built-in libraries. The upload scripts are linked in Upload examples; the same project and course_id work for both operations.
Set the shared credentials from that section, then these values before running an example. In your LMS application, obtain name/email from the authenticated learner and check their course access rather than trusting browser input. These environment values are for trying the route on your server.
export SCORMBOX_COURSE_ID="COURSE_ID_FROM_UPLOAD"
export SCORMBOX_ORIGIN="https://lms.example.com"
export SCORMBOX_LEARNER_NAME="John Doe"
export SCORMBOX_LEARNER_EMAIL="john@example.com"The API does not expose courses/results to callers without valid project credentials. Never place these examples or the secret in browser code. The session response contains a launch capability; return only its launch_token to your authorized learner's page and keep it out of logs. These examples do not automatically retry session creation. For new/reset with retries, add a stable Idempotency-Key as described in Create a session.
JavaScript
Node.js 20+, save as session-result.mjs, run node session-result.mjs
const required = name => {
const value = process.env[name];
if (!value) throw new Error("Set " + name);
return value;
};
const authorization = "Basic " + Buffer.from(
required("SCORMBOX_API_KEY") + ":" + required("SCORMBOX_API_SECRET")
).toString("base64");
const course_path = "/courses/" + encodeURIComponent(required("SCORMBOX_COURSE_ID"));
const user = {
name: required("SCORMBOX_LEARNER_NAME"),
email: required("SCORMBOX_LEARNER_EMAIL")
};
async function request(path, body) {
const response = await fetch("https://scormbox.com/api/v1" + path, {
method: body === undefined ? "GET" : "POST",
redirect: "error",
signal: AbortSignal.timeout(20000),
headers: { Authorization: authorization, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!response.ok) throw new Error("SCORMBox HTTP " + response.status);
return response.json();
}
const session = await request(course_path + "/sessions", {
user, origin: required("SCORMBOX_ORIGIN"), attempt_mode: "resume"
});
// Deliver session.launch_token only to this learner's authorized browser page.
const result = await request(course_path + "/results?" + new URLSearchParams({ email: user.email }));
console.log(JSON.stringify(result, null, 2));Ruby
Ruby 3.1+, save as session-result.rb, run ruby session-result.rb
require "net/http"
require "json"
require "uri"
def request(path, body = nil)
uri = URI("https://scormbox.com/api/v1" + path)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 20
http.read_timeout = 20
message = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
message.basic_auth(ENV.fetch("SCORMBOX_API_KEY"), ENV.fetch("SCORMBOX_API_SECRET"))
message["Content-Type"] = "application/json"
message.body = JSON.generate(body) unless body.nil?
response = http.request(message)
raise "SCORMBox HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
course_path = "/courses/" + URI.encode_www_form_component(ENV.fetch("SCORMBOX_COURSE_ID"))
user = { name: ENV.fetch("SCORMBOX_LEARNER_NAME"), email: ENV.fetch("SCORMBOX_LEARNER_EMAIL") }
session = request(course_path + "/sessions", {
user: user, origin: ENV.fetch("SCORMBOX_ORIGIN"), attempt_mode: "resume"
})
# Deliver session["launch_token"] only to this learner's authorized browser page.
result = request(course_path + "/results?" + URI.encode_www_form(email: user[:email]))
puts JSON.pretty_generate(result)Python
Python 3.9+, save as session-result.py, run python3 session-result.py
import base64
import json
import os
import urllib.parse
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
opener = urllib.request.build_opener(NoRedirect)
credentials = os.environ["SCORMBOX_API_KEY"] + ":" + os.environ["SCORMBOX_API_SECRET"]
authorization = "Basic " + base64.b64encode(credentials.encode()).decode()
def request(path, body=None):
data = None if body is None else json.dumps(body).encode()
message = urllib.request.Request(
"https://scormbox.com/api/v1" + path,
data=data, method="GET" if body is None else "POST",
headers={"Authorization": authorization, "Content-Type": "application/json"}
)
with opener.open(message, timeout=20) as response:
return json.load(response)
course_path = "/courses/" + urllib.parse.quote(os.environ["SCORMBOX_COURSE_ID"], safe="")
user = {"name": os.environ["SCORMBOX_LEARNER_NAME"], "email": os.environ["SCORMBOX_LEARNER_EMAIL"]}
session = request(course_path + "/sessions", {
"user": user, "origin": os.environ["SCORMBOX_ORIGIN"], "attempt_mode": "resume"
})
# Deliver session["launch_token"] only to this learner's authorized browser page.
result = request(course_path + "/results?" + urllib.parse.urlencode({"email": user["email"]}))
print(json.dumps(result, indent=2))PHP
PHP 8+ with HTTPS/OpenSSL streams, save as session-result.php, run php session-result.php
<?php
function required(string $name): string {
$value = getenv($name);
if ($value === false || $value === '') throw new RuntimeException('Set ' . $name);
return $value;
}
function request(string $path, ?array $body = null): array {
$authorization = base64_encode(required('SCORMBOX_API_KEY') . ':' . required('SCORMBOX_API_SECRET'));
$context = stream_context_create(['http' => [
'method' => $body === null ? 'GET' : 'POST',
'header' => "Authorization: Basic $authorization\r\nContent-Type: application/json\r\n",
'content' => $body === null ? '' : json_encode($body, JSON_THROW_ON_ERROR),
'timeout' => 20, 'follow_location' => 0, 'ignore_errors' => true
]]);
$response = @file_get_contents('https://scormbox.com/api/v1' . $path, false, $context);
if ($response === false) throw new RuntimeException('SCORMBox request failed.');
preg_match('/^HTTP\/\S+ (\d+)/', $http_response_header[0] ?? '', $match);
$status = (int)($match[1] ?? 0);
if ($status < 200 || $status >= 300) throw new RuntimeException('SCORMBox HTTP ' . $status);
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
$course_path = '/courses/' . rawurlencode(required('SCORMBOX_COURSE_ID'));
$user = ['name' => required('SCORMBOX_LEARNER_NAME'), 'email' => required('SCORMBOX_LEARNER_EMAIL')];
$session = request($course_path . '/sessions', [
'user' => $user, 'origin' => required('SCORMBOX_ORIGIN'), 'attempt_mode' => 'resume'
]);
// Deliver $session['launch_token'] only to this learner's authorized browser page.
$result = request($course_path . '/results?' . http_build_query(['email' => $user['email']]));
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n";C#
.NET 10+ SDK, save as session-result.cs, run dotnet run --file ./session-result.cs
#:property PublishAot=false
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static string Required(string name) => Environment.GetEnvironmentVariable(name)
?? throw new InvalidOperationException("Set " + name);
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) {
BaseAddress = new Uri("https://scormbox.com/api/v1/"),
Timeout = TimeSpan.FromSeconds(20)
};
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes(Required("SCORMBOX_API_KEY") + ":" + Required("SCORMBOX_API_SECRET"))));
async Task<string> Request(string path, object? body = null) {
using var message = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, path);
if (body is not null) message.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await client.SendAsync(message);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
var course_path = "courses/" + Uri.EscapeDataString(Required("SCORMBOX_COURSE_ID"));
var user = new { name = Required("SCORMBOX_LEARNER_NAME"), email = Required("SCORMBOX_LEARNER_EMAIL") };
using var session = JsonDocument.Parse(await Request(course_path + "/sessions", new {
user, origin = Required("SCORMBOX_ORIGIN"), attempt_mode = "resume"
}));
// Deliver session.RootElement.GetProperty("launch_token").GetString() only to this learner's authorized page.
var result = await Request(course_path + "/results?email=" + Uri.EscapeDataString(user.email));
Console.WriteLine(result);Java
Java 17+ JDK, save as SessionResult.java, run java SessionResult.java
Java's standard library includes an HTTP client but no JSON parser. This example writes the small request with escaped strings and keeps the JSON response as text; use your LMS's JSON library when passing launch_token to a browser.
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
class SessionResult {
static String required(String name) {
String value = System.getenv(name);
if (value == null || value.isEmpty()) throw new IllegalArgumentException("Set " + name);
return value;
}
static String quote(String value) {
StringBuilder result = new StringBuilder("\"");
for (char c : value.toCharArray()) {
if (c == '"' || c == '\\') result.append('\\').append(c);
else if (c < 32) result.append(String.format("\\u%04x", (int)c));
else result.append(c);
}
return result.append('"').toString();
}
public static void main(String[] args) throws Exception {
String authorization = "Basic " + Base64.getEncoder().encodeToString(
(required("SCORMBOX_API_KEY") + ":" + required("SCORMBOX_API_SECRET")).getBytes(StandardCharsets.UTF_8));
String course_path = "https://scormbox.com/api/v1/courses/" +
URLEncoder.encode(required("SCORMBOX_COURSE_ID"), StandardCharsets.UTF_8);
String email = required("SCORMBOX_LEARNER_EMAIL");
String payload = "{\"user\":{\"name\":" + quote(required("SCORMBOX_LEARNER_NAME")) +
",\"email\":" + quote(email) + "},\"origin\":" + quote(required("SCORMBOX_ORIGIN")) +
",\"attempt_mode\":\"resume\"}";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NEVER).build();
HttpRequest create = HttpRequest.newBuilder(URI.create(course_path + "/sessions"))
.timeout(Duration.ofSeconds(20)).header("Authorization", authorization)
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(payload)).build();
HttpResponse<String> session = client.send(create, HttpResponse.BodyHandlers.ofString());
if (session.statusCode() < 200 || session.statusCode() >= 300)
throw new IllegalStateException("SCORMBox HTTP " + session.statusCode());
// Deliver only the parsed launch_token to this learner's authorized browser page; do not log session.body().
HttpRequest read = HttpRequest.newBuilder(URI.create(course_path + "/results?email=" +
URLEncoder.encode(email, StandardCharsets.UTF_8))).timeout(Duration.ofSeconds(20))
.header("Authorization", authorization).GET().build();
HttpResponse<String> result = client.send(read, HttpResponse.BodyHandlers.ofString());
if (result.statusCode() < 200 || result.statusCode() >= 300)
throw new IllegalStateException("SCORMBox HTTP " + result.statusCode());
System.out.println(result.body());
}
}