Skool has no public API. No REST docs, no OAuth, no webhooks, no official SDK. Search for "Skool API" and you mostly find people asking whether one exists.
It doesn't — but the product talks to its own backend, and those endpoints can be driven programmatically. This guide is the complete working reference: every action, what it does, a runnable example, and the traps. Welcoming members, replying to posts, publishing the weekly update with a poll, building courses, exporting your member list — all of it.
Everything here runs through the Skool All-in-One API actor on Apify, which packages the reverse-engineering: login, credential rotation, retries, and guards for the failures that don't announce themselves. Every request below has been run against a real community.
Contents
- Why automating Skool is harder than it looks
- Setup
- The action model
- System and authentication
- Posts: reading
- Posts: writing
- Comments and replies
- Polls
- Images and files
- Emailing every member
- Members
- Classroom (courses)
- Groups and events
- Notifications
- Why a 200 means nothing here
- Rate limits and quotas
- Complete example
Why automating Skool is harder than it looks
The endpoint shapes are the easy part. What makes a Skool integration expensive to maintain is everything around them.
Two backends with different rules. Reads go through Next.js SSR (/_next/data/{buildId}/...); writes go through api2.skool.com. Different auth surface, different failure modes.
Two credentials on different clocks. The WAF token lasts ~3.5 days. The buildId rotates whenever Skool deploys, roughly weekly. A stale buildId breaks reads with a 404; an expired WAF token breaks writes with a 403. Both need detection and a retry with a refreshed credential — otherwise your automation works for three days and then quietly stops.
Login needs a real browser. The WAF inspects the handshake, so you can't just POST credentials. You drive a headless login, extract three cookies (auth_token, client_id, aws-waf-token — all three required), and cache them.
And several operations lie. They return 200 while doing nothing at all. There's a whole section on this below, because it's the part that costs people the most time.
Setup
1. Create an Apify account
Sign up free. The free tier includes monthly platform credit — enough to work through this guide.
2. Open the actor
Skool All-in-One API. Three ways to run it, all equivalent:
- From the Apify Console — fill the input form, hit Start. Best for exploring.
- Over HTTP — the pattern used throughout this guide.
- Through an integration — n8n, Make, Zapier, Python, MCP (so Claude or any MCP client drives your community directly), Claude Code, LangChain, and more.
3. First run — from the Console, nothing to configure
Hit Start with this input:
{ "action": "system:health" }
[{ "success": true, "action": "system:health", "ok": true, ... }]
No token, no Skool credentials, no side effects, ~2s. Start here so you see it working before anything can go wrong.
4. Get your API token
Settings → API & Integrations in the Apify Console. This is what authenticates HTTP calls:
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": "system:health" }'
Paste the token literally the first time rather than using a shell variable.
One error worth recognising, because the wording is misleading:
{ "error": { "type": "x402-payment-required", "message": "x402 payment header missing. Add your PAYMENT-SIGNATURE or Apify token to proceed." } }It means your token didn't reach Apify — not that you owe money. The usual cause is an unset shell variable:
token=$APIFY_TOKENwith nothing in it sendstoken=, and Apify rejects the call as unauthenticated. Paste the token literally and it clears.
5. Authenticate with Skool
{
"action": "auth:login",
"email": "you@example.com",
"password": "...",
"groupSlug": "your-community"
}
Returns a cookie string. Pass cookies instead of email/password on every subsequent call — those run in ~2s instead of ~10s and skip the browser login entirely. Cache it; refresh when you get an auth error, roughly every 3 days.
What it costs
Pay-per-event — you pay for what you do, not for compute time:
| Event | Price |
|---|---|
| Actor start | $0.01 per run |
| Dataset result | $0.005 each |
| Write operation (create / update / delete / approve…) | $0.01 each |
| Browser-based login | $0.05 |
Platform compute (~$0.002/run) included. Approving 10 pending members lands around $0.10. Because cookie auth is cached, the login fee hits once every ~3.5 days rather than per call.
The action model
Every call has the same shape:
{
"action": "domain:operation",
"cookies": "...",
"groupSlug": "your-community",
"params": { }
}
41 actions across nine domains: system, auth, posts, members, classroom, files, groups, events, notifications.
One structural fact explains half of this API: posts and comments are the same object in Skool. There's no comments resource. A comment is a post carrying rootId and parentId. Once that clicks, most of what follows stops being surprising.
System and authentication
| Action | What it does |
|---|---|
system:health |
Liveness check. No auth, no Skool calls, deterministic. Use it for monitoring. |
system:debug |
Environment diagnostics — versions, config. For troubleshooting your setup. |
auth:login |
Browser login; returns the cookie string to reuse for ~3.5 days. |
{ "action": "system:health" }
{ "action": "system:debug" }
Posts: reading
posts:list — a page of the feed
{
"action": "posts:list",
"params": { "page": 1, "sortType": "newest-cm", "category": "<label id>" }
}
Sort types: newest-cm (newest by comment), top, trending, oldest. category filters to one label.
posts:filter — the feed, narrowed server-side
{
"action": "posts:filter",
"params": {
"since": "2026-07-01T00:00:00Z",
"until": "2026-07-31T00:00:00Z",
"notAnsweredBy": "<your user id>",
"maxPosts": 100
}
}
notAnsweredBy is the useful one: it returns posts you haven't replied to. That single param is most of a "reply to unanswered questions" workflow, without paginating the feed yourself.
posts:get — one post
{ "action": "posts:get", "params": { "postId": "..." } }
Use this as your verification step after every write. More on that below.
posts:getComments — the comment tree
{ "action": "posts:getComments", "params": { "postId": "..." } }
Returns ~25-30 top-level comments with replies nested. Fine for typical posts.
posts:getCommentsFull — the entire thread
{ "action": "posts:getCommentsFull", "params": { "postId": "..." } }
Walks the whole thread. A 1,095-comment welcome thread returns complete in about 5 seconds.
Worth knowing why that's notable: the comment endpoint does paginate, but not with any parameter you'd guess — page, p, offset, cursor, before, after are all rejected. The real cursor is created-gt, a 16-digit microsecond timestamp echoed back as last. It's easy to conclude there's no pagination and go build a browser scraper (which is what happened here first: ~5 minutes per thread instead of ~5 seconds), until someone watched what the UI requests when you click "Show more comments".
The transferable lesson: when the UI can do something the API supposedly can't, open the network tab before building a workaround.
Posts: writing
posts:create — publish a post
{
"action": "posts:create",
"params": {
"title": "My post title",
"content": "Plain text body.",
"labelId": "<category id>"
}
}
Content is plain text. Not HTML, not markdown — <p> renders literally as <p>. The only rich-content surface in Skool is course pages (TipTap, via classroom:*).
Mentions use a custom scheme: [@Name](obj://user/{userId}), with the 32-hex id from members:list. Mentions notify even when added in an edit.
Optional params — each covered in its own section below: notifyAll, pollId, attachmentId, videoIds.
If your community requires categories, a post without one fails:
{ "success": false, "errorCode": "MISSING_CATEGORY",
"hint": "This Skool group requires posts to have a category. Pass params.labelId" }
Get label ids from groups:get. They're per group — an id from one community returns label not found in another. Some categories are also role-restricted, so if a post fails on permissions rather than validation, check the category before digging through auth.
posts:update — edit a post or comment
{ "action": "posts:update", "params": { "postId": "...", "title": "...", "content": "..." } }
Preserves comments and votes. Same action edits comments — they're the same object.
posts:delete
{ "action": "posts:delete", "params": { "postId": "..." } }
Deleting a post cascades to its comments. Deleting a comment takes the same action with the comment's id.
posts:pin / posts:unpin
{ "action": "posts:pin", "params": { "postId": "..." } }
{ "action": "posts:unpin", "params": { "postId": "..." } }
Both return an empty body — the success response carries no information at all. Confirm by re-fetching and reading the pinned state.
posts:vote — like / unlike
{ "action": "posts:vote", "params": { "postId": "...", "vote": "up" } }
Pass "" to remove the like.
Comments and replies
{
"action": "posts:createComment",
"params": {
"content": "Nice work.",
"rootId": "<post id>",
"parentId": "<post id for top-level, comment id for a reply>"
}
}
-
Top-level comment:
rootId=parentId= the post id -
Reply to a comment:
rootId= the post id,parentId= the comment id
rootId is always the original post, never the comment you're answering. That's the most common mistake with this API.
The 2-level limit
Skool comments only support two levels. Reply to a reply — a third level — and you get 200 with a normal-looking response body. The comment is never created. Nothing indicates failure.
If you're building anything that replies inside threads, check depth before posting and verify by re-reading the thread. This one silently swallows entire batches.
Polls
Two calls, because a poll is its own object before a post can reference it:
{ "action": "posts:createPoll", "params": { "options": ["Option A", "Option B"] } }
{ "pollId": "a0169196540f40f082039657d2b17755" }
{
"action": "posts:create",
"params": {
"title": "Weekly question",
"content": "What should we build next?",
"labelId": "...",
"pollId": "a0169196540f40f082039657d2b17755"
}
}
A Skool poll has no title or question field. It's a bare list of options — the question is the post's content. There's nowhere else to put it, and this surprises everyone exactly once.
Two options minimum; empty and whitespace-only entries are dropped. Results come back on the post as pollData (read-only) with counts and voters per option.
Images and files
files:uploadImage — public images
{
"action": "files:uploadImage",
"params": {
"filePath": "/path/to/changelog.png",
"fileName": "changelog.png",
"contentType": "image/png"
}
}
Returns a file id. Use it for post attachments, course covers and group icons:
{ "action": "posts:create",
"params": { "title": "...", "content": "...", "labelId": "...",
"attachmentId": "<file id>" } }
attachmentId takes one file id, not a list. Set contentType correctly — the public URL's extension derives from it, so a PNG uploaded as JPEG gets a .jpg URL that 404s.
files:uploadFile — private lesson resources
{
"action": "files:uploadFile",
"params": {
"filePath": "/path/to/template.pdf",
"fileName": "template.pdf",
"contentType": "application/pdf"
}
}
For PDFs, ZIPs and JSONs attached to a lesson via classroom:updateResources.
These two are not interchangeable
Skool's file registration carries a privacy flag: public assets versus private ones served through signed URLs. Use the private path for a post image and here's what you get: upload 200, storage 200, post created 200, and the post renders an empty grey box. The server even postprocesses the image and computes correct thumbnails. It just never issues a public read URL, so the asset 403s for every viewer.
files:uploadImage sends the right flag and fails loudly if no public URL comes back. In the other direction Skool is helpful: a public file used as a lesson resource is rejected outright with 400 invalid file.
Post images → uploadImage. Lesson resources → uploadFile.
Emailing every member
The "Send email to all members" toggle — the most requested automation, and the one with the most edges.
{
"action": "posts:create",
"params": {
"title": "Monthly changelog",
"content": "Everything that shipped.",
"labelId": "...",
"notifyAll": true
}
}
1. Admin-only.
2. It cannot be added afterwards. Skool offers the email only at creation time — no endpoint, no button, to notify about a post that already exists. Publish without it and your only recourse is delete and re-post.
3. Underneath it's an unvalidated parameter. The flag isn't a body field; it's a query param on the create call, and Skool accepts any value. Measured against a test community, checking a real inbox each time:
| What gets sent | HTTP | Email delivered? |
|---|---|---|
| the correct value | 200 | yes |
| parameter absent | 200 | no |
| empty value | 200 | no |
| a typo | 200 | no |
A typo emails nobody, creates the post anyway, and reports success. On a schedule that means your announcement can go out to zero people for months with green checks the whole time. That's why notifyAll is a strict boolean here and the literal is built in one place — the string "true" is rejected rather than guessed at.
The broadcast quota
Roughly one broadcast per 72 hours per group. A second inside the window fails with notify limit exceeded — and the rejection is atomic: the post is not created.
Verified by looking for orphans after a failed call; there are none. The intuitive reading is the opposite ("the post went out, the email didn't"), and a retry built on that assumption double-posts once the quota frees.
If you run more than one recurring broadcast they compete for the same window. A Friday post and a Monday post can leave a margin of hours: a Friday broadcast at 11:54 UTC frees the window at 11:54 UTC Monday, so a Monday post at 14:00 UTC clears it by about two hours. Publish Friday's late and Monday's email silently doesn't go out. Map your cadence against the window before automating both.
Members
| Action | What it does |
|---|---|
members:list |
Active members (paginated) |
members:pending |
Pending approval requests |
members:approve |
Approve one pending member |
members:reject |
Reject one |
members:ban |
Ban a member |
members:batchApprove |
Approve many in one run |
members:export |
Full member export — the only source of email addresses |
{ "action": "members:list", "params": { "page": 1 } }
{ "action": "members:pending", "params": {} }
{ "action": "members:approve", "params": { "memberId": "..." } }
{ "action": "members:batchApprove", "params": { "memberIds": ["...", "..."] } }
{ "action": "members:export", "params": {} }
The trap: memberId is not the user's id. Approve, reject and ban need the membership id, not the account id. Both exist on the same object, and passing the wrong one returns a 404 that reads like the member doesn't exist.
members:list does not include email addresses. Only members:export does.
Classroom (courses)
Courses are a tree: course → folder → page. Page bodies are TipTap JSON, but you author Markdown and the actor converts.
| Action | What it does |
|---|---|
classroom:listCourses |
All top-level courses |
classroom:getTree |
Full recursive tree of one course |
classroom:createCourse |
Create a top-level course |
classroom:createFolder |
Create a folder inside a course |
classroom:createPage |
Create a lesson page |
classroom:setBody |
Write a page's body (and title) |
classroom:updateCourse |
Update course settings |
classroom:updateResources |
Replace the downloadable files under a lesson |
classroom:deleteUnit |
Delete a course, folder or page (cascades) |
{ "action": "classroom:createCourse",
"params": { "title": "Getting Started", "desc": "...", "privacy": 0, "minTier": 0 } }
{ "action": "classroom:createFolder",
"params": { "parentCourseId": "<course id>", "title": "Module 1" } }
{ "action": "classroom:createPage",
"params": { "courseId": "<course id>", "parentId": "<folder id>", "title": "Lesson 1.1" } }
{ "action": "classroom:setBody",
"params": { "pageId": "<page id>", "title": "Lesson 1.1",
"bodyMarkdown": "## Intro\n\nWrite Markdown here." } }
Three things that will save you a rebuild:
-
setBodywrites body and title together. Omit the title and it gets blanked. -
updateCoursedoes a read-then-write on purpose. Skool's partial update silently resetsprivacyto public when fields are omitted. The actor reads the course first and merges, so anything you don't pass is preserved. -
updateResourcesreplaces the whole list — no patch semantics. Pass[]to clear. File ids must come fromfiles:uploadFile.
{ "action": "classroom:updateResources",
"params": { "courseId": "...", "pageId": "...",
"resources": [{ "title": "Workflow JSON", "file_id": "..." }] } }
Groups and events
| Action | What it does |
|---|---|
groups:get |
The group object — id, metadata, category label ids |
groups:setAutoDM |
The automatic DM new members receive |
events:list |
Calendar events |
events:upcoming |
Upcoming events only |
{ "action": "groups:get", "params": { "slug": "your-community" } }
{ "action": "groups:setAutoDM",
"params": { "message": "Welcome #NAME# 👋 Start here: ..." } }
{ "action": "events:upcoming", "params": {} }
groups:get is where you fetch label ids, so it's usually your first call after login. setAutoDM supports #NAME# and #GROUPNAME# tokens, 300 characters max.
Notifications
| Action | What it does |
|---|---|
notifications:list |
Your notification feed |
notifications:markRead |
Mark one as read |
notifications:markAllRead |
Mark everything as read |
{ "action": "notifications:list", "params": {} }
{ "action": "notifications:markRead", "params": { "id": "..." } }
{ "action": "notifications:markAllRead", "params": {} }
Useful as a safety net: anything that needs your attention and slipped past your other checks shows up here.
Why a 200 means nothing here
Notice the pattern across everything above:
| Operation | Silent failure | Real check |
|---|---|---|
| Third-level comment reply |
200, comment never appears |
Re-read the thread |
| Broadcast with a bad notify value |
200, nobody emailed |
Check an inbox |
| Post image uploaded as private |
200 everywhere, grey box |
Confirm a public URL came back; open the post |
pin / unpin
|
Empty body, always | Re-fetch, read the pinned state |
setBody without a title |
200, title silently blanked |
Re-fetch the page |
| Broadcast over quota |
400, and no post created
|
Don't assume a partial write |
There's a second layer if you're scripting this. The actor follows a never-throw policy: on failure it returns {"success": false, "errorCode": ..., "hint": ...} and the run finishes as succeeded. That's deliberate — a bad input shouldn't kill a batch — but it means counting results without checking success reports a failure as a pass:
const row = Array.isArray(items) ? items[0] : items;
if (row?.success === false) throw new Error(`${row.errorCode}: ${row.error}`);
Three lines, and the difference between "1 member exported" and an unnoticed 401.
If you build one thing beyond the happy path, make it verification: re-fetch after every write and assert the change landed. A 2xx means "I received your request", not "I did what you wanted".
Rate limits and quotas
- Reads: ~60/min is safe
- Writes: ~20-30/min before Skool returns 429
-
Comments per call: ~25-30 (use
getCommentsFullfor whole threads) - Broadcasts: ~1 per 72h per group
- Cookies: ~3.5 days
- Auto-DM message: 300 characters
Back off on 429 instead of retrying immediately, and don't parallelize writes — nothing to gain, an account to lose.
Test somewhere disposable
Create a second Skool community with a couple of test accounts and point every write test there. Broadcasts especially: each rehearsal is a real, unrecallable email to every member.
Put a hard guard in code, not a comment:
if (groupSlug === PRODUCTION_SLUG) throw new Error('Refusing to run against production.');
The slug is a variable, and a copy-paste changes it without anyone noticing.
Complete example
The weekly community update — image, poll, and email to every member:
const run = (action, params) =>
fetch(`https://api.apify.com/v2/acts/cristiantala~skool-all-in-one-api/run-sync-get-dataset-items?token=${APIFY_TOKEN}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, params, cookies, groupSlug }),
})
.then(r => r.json())
.then(items => {
const row = Array.isArray(items) ? items[0] : items;
if (row?.success === false) throw new Error(`${row.errorCode}: ${row.error}`);
return row;
});
// 0. category ids live on the group
const group = await run('groups:get', { slug: groupSlug });
// 1. image — uploadImage, never uploadFile
const image = await run('files:uploadImage', {
filePath: './changelog.png', fileName: 'changelog.png', contentType: 'image/png',
});
// 2. poll — no question field; that's the post content
const { pollId } = await run('posts:createPoll', {
options: ['A course', 'A live session', 'More templates'],
});
// 3. the post, emailing every member
const post = await run('posts:create', {
title: 'This week in the community',
content: 'Everything that shipped.\n\nWhat should we build next?',
labelId, pollId, attachmentId: image.fileId, notifyAll: true,
});
// 4. verify by effect, never by status code
const check = await run('posts:get', { postId: post.id });
Get it here: Skool All-in-One API on Apify. New to Apify? Sign up free.
Full documentation
Open and free, and deeper than one post can go:
Reference
- Getting started
- Authentication — cookies, expiry, rotation
- Posts — every param, the comment model, pagination
- Members — list, export, approve, ban
- Classroom — courses, modules, lesson bodies
- Files — uploads, image vs lesson resource
- Notifications
- All actions
- Error handling
Recipes
- Weekly changelog with poll + broadcast
- Auto-approve members with n8n
- Reply to unanswered posts
- Auto-DM new members
- Export members to CSV
- Mirror your newsletter as a Skool post
- Publish a course from Markdown
- Attach files to lesson pages
- Community analytics to NocoDB
- All 20+ recipes
Automating something on Skool that isn't covered here? Say so in the comments — the gaps people hit are what the next recipes get written about.
Disclosure: the Apify links are affiliate links, and the actor is mine. The documentation is free and open.
Top comments (0)