GuidesGenerate a SCORM package programmatically

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.

By · Founder of Outworx, builds UnderlayerPublished

The short version

Short answer
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.

create the coursebash
curl -X POST https://underlayerhq.com/api/v1/courses \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "title": "Refund policy" }'
add screens and a quizbash
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 }
        ] }
    ]}
  ]
}'
set what passing meansbash
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:

export as SCORM 2004bash
curl -L -H "Authorization: Bearer sk_live_..." \
"https://underlayerhq.com/api/v1/courses/c_123/scorm?version=scorm2004" \
-o refund-policy.zip
  • version — scorm2004 (4th Edition, the default) or scorm12. 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:

import a packagebash
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

  1. Build first, export last: the package is made from the course as it is at that moment, so re-export after every change.
  2. Pick the version the LMS actually handles. When in doubt, generate both and let the customer try 2004 first.
  3. 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.
  4. 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.

Keep reading