DEV Community

Cristian Tala
Cristian Tala

Posted on

Skool API: the complete guide to automating your community (41 actions, with examples)

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

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 integrationn8n, 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" }
Enter fullscreen mode Exit fullscreen mode
[{ "success": true, "action": "system:health", "ok": true, ... }]
Enter fullscreen mode Exit fullscreen mode

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" }'
Enter fullscreen mode Exit fullscreen mode

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_TOKEN with nothing in it sends token=, 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"
}
Enter fullscreen mode Exit fullscreen mode

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": { }
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

Posts: reading

posts:list — a page of the feed

{
  "action": "posts:list",
  "params": { "page": 1, "sortType": "newest-cm", "category": "<label id>" }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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": "..." } }
Enter fullscreen mode Exit fullscreen mode

Use this as your verification step after every write. More on that below.

posts:getComments — the comment tree

{ "action": "posts:getComments", "params": { "postId": "..." } }
Enter fullscreen mode Exit fullscreen mode

Returns ~25-30 top-level comments with replies nested. Fine for typical posts.

posts:getCommentsFull — the entire thread

{ "action": "posts:getCommentsFull", "params": { "postId": "..." } }
Enter fullscreen mode Exit fullscreen mode

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>"
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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": "..." } }
Enter fullscreen mode Exit fullscreen mode

Preserves comments and votes. Same action edits comments — they're the same object.

posts:delete

{ "action": "posts:delete", "params": { "postId": "..." } }
Enter fullscreen mode Exit fullscreen mode

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": "..." } }
Enter fullscreen mode Exit fullscreen mode

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" } }
Enter fullscreen mode Exit fullscreen mode

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>"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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"] } }
Enter fullscreen mode Exit fullscreen mode
{ "pollId": "a0169196540f40f082039657d2b17755" }
Enter fullscreen mode Exit fullscreen mode
{
  "action": "posts:create",
  "params": {
    "title": "Weekly question",
    "content": "What should we build next?",
    "labelId": "...",
    "pollId": "a0169196540f40f082039657d2b17755"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Returns a file id. Use it for post attachments, course covers and group icons:

{ "action": "posts:create",
  "params": { "title": "...", "content": "...", "labelId": "...",
              "attachmentId": "<file id>" } }
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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": {} }
Enter fullscreen mode Exit fullscreen mode

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." } }
Enter fullscreen mode Exit fullscreen mode

Three things that will save you a rebuild:

  • setBody writes body and title together. Omit the title and it gets blanked.
  • updateCourse does a read-then-write on purpose. Skool's partial update silently resets privacy to public when fields are omitted. The actor reads the course first and merges, so anything you don't pass is preserved.
  • updateResources replaces the whole list — no patch semantics. Pass [] to clear. File ids must come from files:uploadFile.
{ "action": "classroom:updateResources",
  "params": { "courseId": "...", "pageId": "...",
              "resources": [{ "title": "Workflow JSON", "file_id": "..." }] } }
Enter fullscreen mode Exit fullscreen mode

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": {} }
Enter fullscreen mode Exit fullscreen mode

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": {} }
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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 getCommentsFull for 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.');
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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

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)