DEV Community

Cover image for The Complete Skool API Guide (2026): Posts, Members, Classroom, and 100% Automations
Cristian Tala
Cristian Tala

Posted on

The Complete Skool API Guide (2026): Posts, Members, Classroom, and 100% Automations

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:

  1. The buildId rotates 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.
  2. 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"
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "success": true,
  "cookies": "auth_token=...; client_id=...; aws-waf-token=...",
  "expiresAt": "2026-05-20T22:51:34.636Z",
  "expiresInDays": 3.5,
  "buildId": "1778782667413"
}
Enter fullscreen mode Exit fullscreen mode

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

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

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 .md files 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:


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

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:

  1. 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.

  2. 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.

  3. 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)