The Complete Skool API Guide (2026): Posts, Members, Classroom, and 100% Automations
Skool doesn't have a public API. They've said this publicly, repeatedly, for years.
That's a problem if you run a community on Skool and want to do anything beyond clicking buttons in their UI: bulk-approve members, auto-reply to comments, publish a course from markdown, sync members to your CRM, post the latest podcast episode to your classroom without opening a browser.
I run a Skool community called Cágala, Aprende, Repite (CAR). 484 members, paid tiers, daily activity. There's no way I'm clicking buttons for all of that. So I reverse-engineered the API. Then I built it into an Apify actor so you don't have to.
This guide is everything I learned. Real endpoints, real auth flow, real gotchas — including the ones that cost me a week of debugging.
One thing upfront: every code snippet here shows you how to call the actor on Apify, not the actor's source code. The implementation lives in a private repo for reasons I'll explain at the end. You can run the actor today without touching the source.
How Skool actually works under the hood
Skool runs on two backends, and most third-party tools get this wrong.
Reads go through Next.js SSR endpoints. When you load a community page in your browser, the data comes from https://www.skool.com/_next/data/{buildId}/{slug}.json. Fast, no auth challenge, perfect for scraping.
Writes go through a separate REST API at https://api2.skool.com. Different auth surface, different headers, different error patterns.
This split has two consequences you'll hit:
-
The
buildIdrotates roughly weekly when Skool deploys a new version of their frontend. If your scraper caches a buildId and that build expires, every read returns 404 with no clear hint that you need to refresh. -
Authentication tokens expire on different clocks. The session JWT lasts months, but the Cloudflare WAF token (
aws-waf-token) expires after about 3.5 days. After that, all writes return 403 until you re-authenticate through a fresh browser session.
A naive scraper breaks every 3-7 days for one of these two reasons. The actor handles both automatically.
Authentication: cookies vs Playwright
Skool's login flow is protected by Cloudflare WAF. You can't just POST /login with email + password and get a token. You need a real browser to clear the challenge, which is why the actor uses Playwright for the initial login.
The full cookie set Skool requires:
-
auth_token— JWT, long-lived (months) -
client_id— 32-char hex, stable per session -
aws-waf-token— Cloudflare WAF cookie, expires ~3.5 days
All three are required. Drop any one and writes fail with 403.
The actor exposes an auth:login action that runs the Playwright flow once and returns a cookie string you can reuse:
{
"action": "auth:login",
"email": "you@example.com",
"password": "...",
"groupSlug": "your-skool-group"
}
Response:
{
"success": true,
"cookies": "auth_token=...; client_id=...; aws-waf-token=...",
"expiresAt": "2026-05-20T22:51:34.636Z",
"expiresInDays": 3.5,
"buildId": "1778782667413"
}
Subsequent calls pass the cookies string and skip the Playwright bootstrap. A login takes about 10 seconds. A cookie-authenticated call takes about 2. If you're running a workflow that does many operations, this matters.
When the WAF cookie expires (every 3.5 days), the actor returns a structured error with errorCode: AUTH_REQUIRED and you re-run auth:login. Schedule this in your automation and you never think about auth again.
The endpoint catalog (what you can actually do)
The actor covers every Skool resource I've reverse-engineered. Each one is a separate action in the actor input.
Posts
| Action | What it does |
|---|---|
posts:list |
List posts in a community (with pagination, sort, category filter) |
posts:get |
Fetch a single post by ID, with author, likes, label |
posts:create |
Create a top-level post. Requires labelId if the community has categories (it does) |
posts:update |
Edit title/content/label of an existing post |
posts:delete |
Delete a post (admin only) |
posts:createComment |
Reply to a post or to another comment |
posts:getComments |
Up to ~30 top-level comments per call |
posts:getCommentsFull |
Playwright-based scrape that returns ALL comments in a thread (slower, charged at scrape rate) |
Posts and comments are the same object in Skool's data model. A comment is just a post with rootId (the original post) and parentId (its direct parent). This matters for one specific case I'll get to in a minute.
Members
| Action | What it does |
|---|---|
members:list |
List members of a group |
members:pending |
List waitlist (people who applied but not yet approved) |
members:approve |
Approve a pending member |
members:reject |
Reject a pending member |
members:ban |
Ban an existing member |
members:batchApprove |
Approve multiple at once |
Classroom (courses, folders, lessons)
| Action | What it does |
|---|---|
classroom:createCourse |
Create a top-level course with cover image, privacy, tier gate, drip schedule |
classroom:createFolder |
Add a section (folder) inside a course |
classroom:createPage |
Add a lesson page inside a folder |
classroom:setBody |
Set the lesson body. Accepts markdown and converts to Skool's TipTap JSON format internally |
classroom:updateCourse |
Update course metadata. Read-then-write internally because of a subtle Skool quirk (see "Gotchas" below) |
classroom:updateResources |
Attach uploaded files to a lesson's Resources section |
Files
| Action | What it does |
|---|---|
files:uploadImage |
Upload a cover image. Returns the S3 reference Skool expects |
files:uploadFile |
Upload any file (PDF, ZIP, etc.) for use as a lesson resource. Sets privacy:1 automatically — Skool silently rejects files without it |
Groups
| Action | What it does |
|---|---|
groups:get |
Fetch a group's full metadata: members count, categories (labels), tiers, settings |
Events
| Action | What it does |
|---|---|
events:list |
List events with real next-occurrence times + timezone + occurrenceId for RSVP |
System
| Action | What it does |
|---|---|
system:health |
Cheap, no-auth health check |
system:debug |
Returns Apify egress IP, raw Skool homepage status, extracted buildId, and a full posts:list replication — invaluable when something breaks intermittently |
Common gotchas (the stuff that bit me, so it doesn't bite you)
These aren't theoretical. Each one cost me hours.
1. posts:create requires labelId if your community has categories
If your Skool community has post categories (almost all do), posts:create without labelId returns:
HTTP 422: {"object_type":"post","fields":[{"name":"metadata.labels","error":"You must select a category","user":true}]}
The actor surfaces this as errorCode: MISSING_CATEGORY with a hint. Fetch the available labels with groups:get, then pass the labelId.
2. Top-level comments need parentId = rootId
This is the most unintuitive piece of Skool's data model. To create a top-level comment on a post, you set BOTH parentId AND rootId to the post's ID. To reply to an existing comment, you set rootId to the original post and parentId to the comment you're replying to.
{
"action": "posts:createComment",
"params": {
"rootId": "post-id",
"parentId": "post-id", // same as rootId for top-level
"content": "..."
}
}
Skip this and you get a 404 that says nothing useful.
3. posts:update body shape is flat, not wrapped
posts:create wraps its data in a metadata object. posts:update does NOT — it expects flat fields. Sending the wrapper returns 200 OK and silently ignores your update.
The actor handles the shape difference for you. If you're talking to the Skool API directly, this will burn you for a day. (It burned me for a day.)
4. classroom:updateCourse silently resets privacy to 0
If you PUT a course with a partial body, Skool quietly resets privacy (your paywall) to 0 (Open) and amount (price) to 0. Tier gating is preserved. Privacy and amount are not.
I caught this against twelve production courses. The actor's updateCourse does a read-then-write internally: fetches the current state, merges your input on top, writes the full body. Slower by ~200ms, but your paywall doesn't disappear.
5. BUILDID_STALE 404s are usually transient
Skool deploys a new build of their frontend, your cached buildId goes stale, you get 404s on reads. The actor's retry handler refreshes the buildId from the homepage and retries automatically.
If you see this in your error logs and it self-resolves 30 minutes later, that was a Skool deploy mid-flight (or a Cloudflare blip on Apify's egress IP). The actor logs system:debug snapshots so you can diagnose without redeploying.
6. x402-payment-required ≠ payment problem
If the actor returns x402-payment-required on every action including reads, this is not a billing issue. It's Apify's notice: UNDER_MAINTENANCE flag, triggered automatically when a published actor fails its daily quality check.
The actor's default action is system:health, which never throws — so the quality check stays green. If you ever see this flag, clear it with one PUT to the Apify API. (Documentation in the actor README.)
7. members:approve and members:reject need memberId, not id
Skool returns two IDs per member: id is the global user ID (stable across Skool), memberId is the group-specific membership ID. Approve/reject/ban operations need memberId. Using id returns HTTP 404 with no useful message.
The actor surfaces both fields and uses the correct one. If you're talking to Skool directly, this single mismatch will silently kill a batch of 50 approvals.
How I use this in production
I'm not building this as a toy. CAR has 484 members, 19 courses in the classroom, daily posts, daily approvals, a podcast that syndicates new episodes to the community. None of that is manual.
A few examples (each one is its own deep-dive post — links below):
Publishing podcast episodes to the classroom 100% automatically. New episode lands on
eslahoradeaprender.com→ my pipeline syndicates the show notes to the community classroom as a new lesson with the YouTube thumbnail, all in under 30 seconds. Twelve episodes published this way so far, zero manual clicks.Uploading an 18-course classroom from markdown. When I shipped the curriculum, every course (folders, lessons, covers, tier gates) came from
.mdfiles in a local directory. One script, ~3 hours of total runtime to push 18 courses. The R-PUT-COURSE gotcha above came from this exact project.Daily engagement bot for the community feed. Detects unanswered posts in the community feed, drafts a reply using the community's content (podcast back-catalog, blog, course corpus), sends to Telegram for approval, publishes on click.
If you want the deep-dives:
- How I Publish Podcast Episodes to Skool 100% Automatically
- How I Uploaded an 18-Course Classroom With One Script
Get started
The actor is on Apify, ready to use:
👉 Skool API — Posts, Members & Classroom
Pricing is pay-per-event: $0.005 per result returned, $0.01 per write operation, $0.05 for Playwright-based deep scrapes. No subscription. If you're testing it, you'll spend cents.
A complete 10-line example to create a post in your community:
curl -X POST "https://api.apify.com/v2/acts/cristiantala~skool-all-in-one-api/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"action": "posts:create",
"groupSlug": "your-skool-slug",
"cookies": "auth_token=...; client_id=...; aws-waf-token=...",
"params": {
"title": "Hello from the API",
"content": "First post via Apify.",
"labelId": "your-category-id"
}
}'
For workflow tools, it's a single Apify node in n8n, Make.com, or Zapier. Pass action, groupSlug, cookies, and the action-specific params. Same shape everywhere.
If you're building AI agents (Claude, ChatGPT function calling, LangChain, OpenClaw) that need to interact with a Skool community: same actor, called as a tool. The action names map cleanly to capabilities.
What's next
The actor covers most of Skool's surface area, but not all. On the wishlist (open as GitHub issues on the actor's Apify page):
- Pin to Course Page — pin a post to a specific course view rather than the global feed. Endpoint exists in Skool's UI; needs capture.
- About Page API — programmatically read/write a group's About page.
- Per-page drip schedules — investigating whether Skool supports drip at the lesson level (not just course level).
- YouTube video embed in lessons — Skool generates an internal video ID when you paste a YouTube URL in the editor. We can render thumbnails today, but native embeds need that endpoint captured.
If you have a Skool automation that's currently impossible because the endpoint isn't covered, open an issue. Most of them are an afternoon of Playwright capture away.
Why the source is private
A common question: "Is this open source?"
It's not. The TypeScript SDK behind the actor (skool-js) and the actor wrapper itself live in a private GitHub repo. There are three reasons:
The actor IS the product. If the SDK were public on npm, the conversion path to actual revenue collapses. The actor pays for itself plus my time maintaining the reverse-engineering against Skool's deploys.
Skool deploys frequently. When the WAF flow changes, the buildId path changes, or a new captcha is added, the actor needs a patch within hours. A private repo with paid distribution gives me a sustainable maintenance loop. An open repo with infinite forks does not.
The reverse-engineering itself has cost. Each endpoint captured took 30 minutes to several hours of UI interception. That work compounds in the actor — not in a free clone.
If you need self-hosted SDK access for a specific use case, that's a different conversation; reach out. For everything else, the actor is the fastest path from "I have a Skool community" to "my Skool community runs itself."
If this guide was useful, the best way to help me keep improving the actor is to use it. Every run validates a real use case and tells me what to capture next. Find a gap or hit a gotcha I haven't documented? Open an issue on the actor page — they get answered.
Top comments (0)