Generate a SCORM package programmatically
You can produce a SCORM package without an authoring tool: build the course as data over an API, then ask for it as a zip. With Underlayer that is a handful of curl commands — create the course, fill in its screens, and export it as SCORM 2004 or 1.2 for whichever LMS is waiting for it.
The short version
POST /api/v1/courses to create a course, PATCH it with its screens and blocks, then GET /api/v1/courses/{id}/scorm?version=scorm2004 (or scorm12) returns the package itself. The API export needs the Scale plan; exporting from the dashboard works on every plan.Why generate SCORM from code?
Authoring tools produce one package at a time, by hand. That stops working when courses come from somewhere else — a product's own documentation, a catalogue in a database, a translation pipeline, an AI draft — or when a customer's LMS needs a fresh package every time the content changes. Generating the package from code keeps the course as data you can version, diff and rebuild.
1. Build the course
A course is a list of screens and a screen is a list of blocks — a heading, a paragraph, a quiz question. Create the course, then send its screens. Only the title is required to start.
curl -X POST https://underlayerhq.com/api/v1/courses \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "title": "Refund policy" }'curl -X PATCH https://underlayerhq.com/api/v1/courses/c_123 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"screens": [
{ "id": "s_intro", "title": "The basics", "blocks": [
{ "id": "b_h", "type": "heading", "text": "Refunds in 30 seconds", "level": "h2" },
{ "id": "b_p", "type": "text", "text": "Agents can refund up to $200 without approval." }
]},
{ "id": "s_quiz", "title": "Quick check", "blocks": [
{ "id": "b_q", "type": "quiz_single_choice",
"question": "What can you refund without approval?",
"options": [
{ "id": "o1", "label": "Up to $50", "correct": false },
{ "id": "o2", "label": "Up to $200", "correct": true }
] }
]}
]
}'curl -X PATCH https://underlayerhq.com/api/v1/courses/c_123 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "passingScore": 70 }'passingScore is the percentage a learner must get right to pass; the package reports pass or fail against it. The block reference has every block type with a working example — start there, because a block with a misspelled field saves cleanly and then renders empty.
2. Export the package
The export endpoint returns the zip itself, built from the live course data at that moment:
curl -L -H "Authorization: Bearer sk_live_..." \
"https://underlayerhq.com/api/v1/courses/c_123/scorm?version=scorm2004" \
-o refund-policy.zipversion—scorm2004(4th Edition, the default) orscorm12. Use 1.2 when an LMS imports 2004 but never records a completion; the differences explain why that happens.lang— package a translation instead of the source language, for a customer who needs the course in theirs.track=1— have the package report runs back to you as well as to the LMS (see step 3).
The API export requires the Scale plan or above; a Sandbox or Build key gets a 403 with plan_required. Exporting from a course's page in the dashboard works on every plan.
What is inside the zip
The package is self-contained: the course content plus a standalone build of the same player the embed uses, so it runs entirely from the LMS's own origin. The whole course is one SCO, because the course keeps state across screens — running scores, a results screen, branching — and that needs one continuous session. Screens are reported as cmi.location bookmarks, so learners resume where they left off.
The LMS receives completion and pass/fail, the final score, time spent, a resume bookmark, and one interaction per graded question with the learner's answer.
3. Keep the analytics
A SCORM package normally reports only to the LMS running it, so those learners disappear from your own analytics. Exporting with track=1 also sends view, answer and completion events back to Underlayer, keyed to the LMS's own learner id. If you use consistent ids, a learner's activity in a customer's LMS lines up with their activity in your product, and your webhooks fire as usual. The LMS's own SCORM record stays the authoritative one.
Going the other way: importing a package
The reverse works too, for migrating a library in bulk. Post a zip and you get a draft course back, plus a meta.kind that tells an automated migration whether it was restored exactly or only partly:
curl -X POST -H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/zip" \
--data-binary @course.zip \
"https://underlayerhq.com/api/v1/courses/import/scorm"A package Underlayer exported comes back exactly (roundtrip). One from another authoring tool comes back as its outline, readable text and whatever quiz data could be read (salvaged) — questions from QTI and from the course data Rise, Storyline, Captivate and iSpring embed. Answer keys are never guessed.
Things that trip people up
- Build first, export last: the package is made from the course as it is at that moment, so re-export after every change.
- Pick the version the LMS actually handles. When in doubt, generate both and let the customer try 2004 first.
- Keep your learner ids consistent between your product and the LMS if you export with track=1, or the two histories won't join up.
- Don't expect ungraded interactions to score. Matching and hotspot blocks are interactive but ungraded, so the LMS score always equals what the learner saw.