Quickstart
From nothing to a course a learner has finished — eight steps, every one a command you can paste. Budget ten minutes.
You need a terminal and a workspace. Nothing else — no SDK to install, no framework, no build step. Every command below is plain curl. Replace sk_test_... with your own key from step 1 and work down the page.
If you only read one thing: a course is a list of screens, and a screen is a list of blocks. A block is one thing on the page — a heading, a paragraph, a quiz question. That’s the whole model. Everything below is arranging blocks.
Get an API key
Open API Keys in your dashboard and press Create key. Copy it now — it is shown once and only a hash is kept, so if you lose it you revoke it and make another.
Check it works. This is the cheapest possible request and tells you which workspace and plan you’re on:
curl https://underlayer.outworx.io/api/v1/me \
-H "Authorization: Bearer sk_test_..."{ "workspace_id": "w_456", "workspace_name": "Acme", "plan": "build" }Got 401 instead? The header has to be exactly Authorization: Bearer YOUR_KEY — the word Bearer, then a space, then the key.
Create an empty course
Only the title is required. Everything else has a sensible default, and you can change all of it later.
curl -X POST https://underlayer.outworx.io/api/v1/courses \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "title": "Refund policy" }'{
"data": {
"id": "c_123",
"title": "Refund policy",
"status": "draft",
"screens": [],
"passingScore": null,
"quizFeedback": "deferred",
"navigation": "default",
...
}
}Keep that id. Every command from here needs it. The course is a draft, which means its embed link 404s — nobody can reach it until you publish in step 6.
Put something on it
Now the part everyone gets stuck on, so here it is in full. You send the whole screens array. Each screen needs an id, a title and blocks. Each block needs an id and a type, plus whatever that type reads.
curl -X PATCH https://underlayer.outworx.io/api/v1/courses/c_123 \
-H "Authorization: Bearer sk_test_..." \
-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 },
{ "id": "o3", "label": "Anything", "correct": false }
]
}
]
}
]
}'That’s a real two-screen course. There are thirty-eight block types, each with a copy-pasteable example — images, video, flashcards, steps, timelines, accordions, callouts, code, checklists, polls, scenarios, six kinds of question.
Ids are yours to invent — any unique string. They must be unique within the course, because the player remembers a learner’s answers and their place by block and screen id. Reuse one and you’ll lose somebody’s work. If you have no preference, use UUIDs.
PATCH replaces the whole array. Sending one screen deletes the rest. To change a single screen, read the course first, edit that screen, send them all back — or use the MCP screen tools, which do exactly that for you.
Look at it before anyone else does
Open the course in your dashboard and press Preview. That is the same player a learner gets, rendering the same blocks — there is no separate preview renderer to disagree with production.
Decide what passing means
Optional, and only meaningful if the course has questions. Set passingScore to the percentage a learner must get right to pass and earn a certificate. Leave it null and finishing at all counts as passing.
curl -X PATCH https://underlayer.outworx.io/api/v1/courses/c_123 \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "passingScore": 70, "quizFeedback": "immediate" }'quizFeedback decides when a learner finds out. immediate marks each answer as they give it, which suits practice; deferred (the default) holds everything to the end, which suits a test.
Publish it
curl -X PATCH https://underlayer.outworx.io/api/v1/courses/c_123 \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "status": "published" }'The embed link is now live. Unpublish any time by setting "status": "draft" — the link goes back to 404ing and existing progress is kept.
Show it to a learner
Drop it into your own product in an iframe. There is no portal, no separate login, and nothing for the learner to sign up for.
<iframe
src="https://underlayer.outworx.io/embed/c_123?identity=usr_8f2k"
width="100%"
height="600"
style="border:0"
></iframe>identity is your id for that user — whatever your own database calls them. Your backend builds this URL, so your backend decides who this is. Leave it off and the course still works, but the run is anonymous: nothing is recorded against a person, nothing resumes, and no certificate can be issued. Adding it is the single highest-value thing on this page.
See what happened
Take the course yourself in that iframe, then ask:
curl "https://underlayer.outworx.io/api/v1/completions?courseId=c_123" \
-H "Authorization: Bearer sk_test_..."{
"data": [
{
"identityExternalId": "usr_8f2k",
"status": "completed",
"score": 100,
"passed": true,
"timeSpentSeconds": 48,
"progressPercent": 100,
"completedAt": "2026-08-01T09:18:52.000Z"
}
],
"pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}That’s the loop closed. You can also be told instead of asking — register a webhook and we’ll POST you the moment someone finishes.
Where to go next
- Block reference — All 39 block types with a working example of each.
- Concepts — Courses, screens, blocks, learners, scoring — the vocabulary.
- REST API — Every endpoint, every field.
- Errors & limits — What each error means and what to do about it.