How I Uploaded an 18-Course Classroom to Skool With One Script (and the Bug That Almost Killed My Paywall)
When I sat down to build the curriculum for my Skool community, I had two options.
Option A: open the Skool UI, click New Course, type the title, drag in a cover image, click Add section, type the section title, click Add lesson, type the lesson title, paste the body, fix the formatting Skool's editor mangled, save, repeat. For 18 courses with an average of 12 lessons each.
That's ~220 manual operations, each in a UI that adds modal friction at every step. Realistic time: a full week of clicking.
Option B: structure the curriculum as markdown files in a local directory. Write a script that walks the directory and calls Skool's API to create the courses, folders, lessons, and bodies. Run the script. Drink coffee. Done in two hours.
I went with Option B. I'd already built the Skool API actor on Apify for other automations (member approvals, podcast publishing, comment replies), and the classroom endpoints were already there. The actor wraps Skool's reverse-engineered API with read-then-write semantics, markdown-to-Skool's-internal-format conversion, and structured error handling.
What I didn't expect was the bug that nearly stripped the paywall off all 12 of my paid courses. We'll get there.
What the directory looked like
The whole curriculum lived in one folder structure on my laptop. Each course was a subdirectory; each module was a subdirectory inside that; each lesson was a markdown file. Cover images and the script to regenerate them lived next to each course.
cursos-car/
├── intro-llms-mastermap/
│ ├── M01-el-llm-no-es-oraculo/
│ │ ├── L1.1-el-llm-no-es-oraculo-ni-google.md
│ │ ├── L1.2-tokens-y-ventanas-de-contexto.md
│ │ └── L1.3-el-coste-real-de-tokens-a-dolares.md
│ ├── M02-mapa-de-modelos-2026/
│ ├── M03-framework-de-decision/
│ ├── M04-matchmaking/
│ ├── M05-tu-primer-experimento/
│ └── assets/
│ ├── cover.html # Tailwind synthwave template
│ ├── cover.jpg # Playwright-rendered output
│ └── render-cover.mjs # script to regenerate the JPG
│
├── idea-validation/
│ └── [same structure, 15 lessons across 5 modules]
│
├── podcasts-car/
│ └── [12 lessons in one folder, no modules — flat archive]
│
└── [15 more courses]
Each L*.md file is a self-contained lesson. First line is # Title. Body is normal markdown with the occasional callout block.
The build pipeline reads this directory, creates one course on Skool per top-level folder, one Skool section (folder) per M*-* subdirectory, and one Skool lesson per L*.md file. The body of the lesson is the markdown content converted to Skool's internal TipTap JSON format.
The whole pipeline is straightforward to describe. It hides four traps. Let me walk you through them.
Trap 1: Skool prefixes folders with "Section N:" — don't do it manually
My first pass at the directory naming put the module number in the folder name itself: M01-intro, M02-mapa-modelos, etc. The script passed those titles straight through to classroom:createFolder on Skool.
Result in the UI: every folder displayed as "Section 1: M01 — Intro", "Section 2: M02 — Mapa de Modelos", etc. Double-numbered. Awful.
Skool prefixes every section in a course with "Section N:" automatically based on its position. If you also number manually in the title, you get the duplication.
Fix: drop the M01-, M02- prefix from the folder titles you pass to the API. Keep the prefix in your local directory names if you want them ordered on disk — just don't pass them to Skool.
Now my titles are just 🧱 El LLM no es un Oráculo, 🗺️ Mapa de Modelos 2026, etc. Skool renders them as Section 1: 🧱 El LLM no es un Oráculo, which is what I wanted.
Trap 2: classroom:updateCourse silently resets privacy to 0
This is the one that almost cost me real money.
A few weeks into running the pipeline, I needed to update the description on a few courses. Trivial — classroom:updateCourse with the new desc field. Worked fine.
Until I checked the courses in the Skool UI and noticed the lock icons had disappeared. Twelve paid courses were now showing as Open (free) to all members. The Premium tier I'd set was wiped. amount: 147 was now amount: 0.
What happened: Skool's PUT /courses/{courseId} endpoint is a replace, not a merge. If you omit a field in the body, Skool doesn't preserve it from the existing record — it resets it to the default. privacy defaults to 0 (Open). amount defaults to 0. minTier happens to be preserved (because it's an integer with no default that fits "I omitted this"), but privacy and amount are not.
The actor now does updateCourse as read-then-write internally. It fetches the current course state, merges your input on top, sends the full body. Cost: +1 GET (~200ms per update). Worth every millisecond.
But the day I figured this out, I had to fix twelve courses by hand in the UI before any paying member noticed they had free access to everything.
If you build any automation against Skool's classroom endpoints, never send a partial PUT. Always read the current state first, merge, and write the full body. The actor handles this for updateCourse. If you call the Skool API directly: read-then-write everywhere, no exceptions.
Trap 3: Markdown converter quirks
The actor accepts markdown for lesson bodies and converts it to Skool's internal [v2]<JSON_array> TipTap format. The conversion is mostly faithful, but a few things bit me when my source markdown wasn't strict.
Brackets without URLs become plain text. A line like [Read more] - in this guide is not valid markdown link syntax (no parentheses with URL). Most renderers tolerate it. Skool's converter respects the spec — it stays as literal text with brackets visible. Audit your "For more reading" sections before publish. This bit me three times in lessons generated by an AI agent that wasn't strict about link syntax.
### [Sección 1] Title is literal text, not auto-numbered. Two of my lessons had ### [Sección 1] The economy... and ### [Sección 2] When to use them... literally in the markdown. False alarm: I thought the converter was adding the "Sección 1" prefix. It wasn't — the source content had it. Skool renders it verbatim. Strip those patterns from your source before converting.
Headings inside blockquotes get flattened. Skool only renders the blockquote's left border on paragraph children. A real bulletList inside a blockquote visually escapes the callout. The converter handles this by flattening nested lists inside blockquotes to paragraphs with • prefixes. Cosmetic, but a fact to know if your markdown style relies on nested structure inside callouts.
Tables with many columns degrade. Tables that fit (≤5 cols × ≤10 rows × ≤30 chars/cell) render as monospace code blocks (preserves alignment). Larger tables degrade to bulleted lists with bold-key prefixes. Acceptable, but plan your tables accordingly.
Trap 4: Re-running creates a NEW course (course-create is not idempotent)
The script that creates a course top-to-bottom is not idempotent. Re-running it creates an entirely new course with the same name, not an update.
Skool has no "upsert course by name." There's no unique constraint on course title within a community. If your script fails halfway and you re-run it, you now have two half-built courses with the same name.
How I handle this:
-
For initial course creation: dry-run against a test Skool community first. I use
nyx-trial-run-8420for this. Only push to production (CAR) once the test passes. -
For incremental updates (add a lesson, fix a typo): use a different code path that takes the existing
courseIdandpageIdas inputs. No course creation involved. The IDs are checked into the script as constants — yes, hardcoded, on purpose. - For full re-publishes (rare): delete the old course manually in the UI first, then re-run the script.
The pattern I land on for any Skool automation now: the create-course path and the update-course path are different scripts, with different inputs, and only the create-course one ever runs against a test community first.
The actor calls (the actual flow)
Skipping the markdown parsing, the cover image render, and the directory walk — the Skool side of the pipeline is four actor calls per course.
Step 1: Upload the cover image
{
"action": "files:uploadImage",
"groupSlug": "cagala-aprende-repite",
"cookies": "<from auth:login>",
"params": {
"file": "<base64-encoded JPG of the cover>"
}
}
Returns coverImage (the S3 reference) + coverImageFile (a UUID Skool stores separately). You need both for the next call.
Step 2: Create the course
{
"action": "classroom:createCourse",
"groupSlug": "cagala-aprende-repite",
"cookies": "<...>",
"params": {
"title": "🧠 Intro a LLMs · Decide cuál usar",
"desc": "Aprende a elegir el modelo correcto para cada caso de uso.",
"coverImage": "<from step 1>",
"coverImageFile": "<from step 1>",
"privacy": 1,
"minTier": 2,
"state": 1
}
}
Returns courseId. Course is now visible (but empty) in the classroom.
Step 3: For each module folder, create a section
{
"action": "classroom:createFolder",
"groupSlug": "cagala-aprende-repite",
"cookies": "<...>",
"params": {
"parentCourseId": "<from step 2>",
"title": "🧱 El LLM no es un Oráculo",
"state": 1
}
}
Returns the section's folderId.
Step 4: For each lesson markdown, create the page + set the body
{
"action": "classroom:createPage",
"groupSlug": "cagala-aprende-repite",
"cookies": "<...>",
"params": {
"courseId": "<from step 2>",
"parentId": "<folderId from step 3>",
"title": "1.1 El LLM no es un Oráculo ni un Google"
}
}
Returns pageId. Then:
{
"action": "classroom:setBody",
"groupSlug": "cagala-aprende-repite",
"cookies": "<...>",
"params": {
"pageId": "<from above>",
"title": "1.1 El LLM no es un Oráculo ni un Google",
"bodyMarkdown": "<the contents of L1.1-el-llm-no-es-oraculo-ni-google.md>"
}
}
Lesson is live in the classroom.
One detail: delete the placeholder page
Skool auto-creates an empty New page in every new course. The script deletes it after step 4 so the user doesn't see an empty placeholder. The actor exposes this via classroom:deletePage if you need it.
What this saved me
The math on a single course:
| Step | Manual (UI clicks) | Automated (actor calls) |
|---|---|---|
| Create course + cover | ~3 minutes | 2 calls (~2s) |
| Create 5 folders | ~5 minutes (1/folder, modal friction) | 5 calls (~3s) |
| Create 15 lessons + bodies | ~30-45 minutes (1.5-3 min/lesson) | 30 calls (~30s) |
| Total per course | 38-53 minutes | ~35 seconds |
Across 18 courses: roughly 12 hours of clicking vs ~10 minutes of script execution + the time it took to write the script (one afternoon, reusable across all courses).
The first course paid back the entire build investment. The next 17 are pure margin.
Costs on the actor side: ~$0.20 per course at current pricing ($0.005 per result for the 1 result returned by each call, $0.01 per write for ~35 writes per course). Eighteen courses cost less than $4 to publish. Less than the hourly value of fifteen minutes of clicking.
How to copy this for your community
If you're sitting on a directory of course content (markdown, Google Docs, Notion exports) and looking at the manual upload path with dread, the pattern is:
-
Normalize your source content to markdown files in a consistent directory structure. One
.mdfile per lesson, first line is the title, content is everything below. - Render or upload your course covers once. Each course needs a cover; you can either prerender all of them or generate them from a template (I use a Tailwind HTML template + Playwright screenshot).
-
Authenticate once with
auth:login. The actor returns cookies valid for ~3.5 days. -
Walk your directory, calling
classroom:createCourse→classroom:createFolderper module →classroom:createPage+classroom:setBodyper lesson. - Dry-run against a test community first. Spin up a free Skool community for testing if you don't have one. Skool's create-course is not idempotent — get a clean publish out of the test community before pointing the script at production.
- Save the resulting course IDs and page IDs if you'll ever need to update individual lessons. Hardcode them in your incremental-update script.
The whole thing is one Apify actor with one consistent input shape. In n8n / Make.com / Zapier, each call is a single node.
👉 Skool API — Posts, Members & Classroom on Apify
What this unlocks
The directory-as-source-of-truth pattern is what makes the rest of my Skool operations sustainable. The curriculum lives in version-controlled markdown. Editing a lesson is a git commit away from being live. New courses are a copy of an existing directory + node scripts/build-course.mjs.
The Skool UI is now the viewer for community members. The editor is my laptop.
If you want the broader picture of what the actor can do beyond classroom — posts, members, comments, files, events — I wrote the pillar guide here: The Complete Skool API Guide (2026).
And if you want the other case study from this series — how I publish podcast episodes to the classroom 100% automatically with the same actor — that's here: How I Publish Podcast Episodes to Skool 100% Automatically.
Currently testing a similar pipeline for the next 18 courses. If you've automated something on Skool that needs an endpoint not in the actor today, open an issue on the actor's Apify page. That's how the roadmap moves.
Top comments (0)