# Skool API — Posts, Members & Classroom (`cristiantala/skool-all-in-one-api`) Actor

Skool API to automate posts, comments, members and classroom courses on any Skool community where you're admin. Read AND write access. Drop-in for n8n, Make.com, Zapier, AI agents (Claude, ChatGPT,LangChain). Bulk approve members, scrape comment threads, auto-DM, publish courses from markdown.

- **URL**: https://apify.com/cristiantala/skool-all-in-one-api.md
- **Developed by:** [Cristian Tala S.](https://apify.com/cristiantala) (community)
- **Categories:** Social media, Automation, Developer tools
- **Stats:** 276 total users, 75 monthly users, 100.0% runs succeeded, 5 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $5.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Skool API — Automate Posts, Members, Comments & Classroom on Skool.com

**The most complete Skool API on Apify.** Read AND write access to Skool community **posts, comments, members, courses (classroom), file uploads, Auto DM and group settings** — for any Skool community where you have admin rights. Production-ready **Skool automation** for **n8n**, **Make.com**, **Zapier**, **Pipedream**, custom backends, AI agents (**Claude**, **ChatGPT**, **OpenClaw**, **LangChain**), and **Skool community scrapers**.

If you're trying to **automate your Skool community**, **bulk approve Skool members**, **scrape Skool posts and comments**, **send Auto DMs to new members**, **publish courses programmatically**, or integrate **Skool with n8n** — this is the only actor on Apify that does it all. Stop fighting Cloudflare, WAF tokens, and Skool's missing public API. This actor handles the entire automation layer.

**Use cases**: community onboarding automation, member screening with AI, content cross-posting, classroom course publishing from markdown, scheduled posts, comment threading, member analytics, course resource management. Used in production by **CAR (Cágala, Aprende, Repite)**, 700+ members.

### What You Can Build With This Skool API

| Use case | Actions used | Why this works |
|---|---|---|
| **Auto-approve Skool members with AI screening** | `members:pending` + AI scoring + `members:approve` / `members:reject` | Stop manually approving every signup. Filter spam, gauge intent, save hours per week. ([n8n template](https://n8n.io/workflows/14392-auto-approve-skool-community-members-with-gpt-4o-ai-screening/)) |
| **Auto-DM every new Skool member** | `groups:setAutoDM` | One-time setup, runs forever inside Skool. Onboarding nudge with personalized welcome (`#NAME#`, `#GROUPNAME#` tokens). |
| **Cross-post blog → Skool community feed** | `posts:create` + `groups:get` (for labelId) | Syndicate your content automatically. Each newsletter/blog post = 1 community post, fully formatted. |
| **Bulk reply to unanswered Skool posts** | `posts:list` + filter `commentCount === 0` + `posts:createComment` | Stop letting questions sit unanswered. Auto-reply with AI when comments stale > 24h. |
| **Get ALL comments in a Skool thread (>35)** | `posts:getCommentsFull` | One REST call caps at ~35 comments per thread. This action pages past that with Skool's own cursor — the entire thread, replies included, in ~5s per 1000 comments, with real ids and ISO timestamps. |
| **Publish a complete Skool course from markdown** | `classroom:createCourse` + `classroom:createFolder` + `classroom:createPage` + `classroom:setBody` | Write courses as `.md` files in Git. Push → Skool course updated. Markdown → TipTap converter built-in. |
| **Member analytics dashboard for Skool** | `members:list` + `posts:list` + custom export to NocoDB/Airtable | Apify dataset → your BI tool. Track activity, engagement, churn signals. |
| **Skool community moderation bot** | `posts:filter` + LLM classification + `posts:delete` / `members:ban` | Detect spam, off-topic, or policy violations programmatically. Human approval optional. |
| **Welcome challenge for new members** | `groups:setAutoDM` + `posts:createComment` + `members:list` | Day 1: introduce yourself. Day 3: first win. Day 7: share a result. Drives early activation. |
| **Cross-community member migration** | `members:list` (source) + `members:approve` (destination) | Move members between Skool communities you own without manual one-by-one. |

📚 **Full docs, tutorials & 20 production-tested recipes**: [skool-api.cristiantala.com](https://skool-api.cristiantala.com/) — auto-approve members, export members CSV, publish courses from Markdown, attach files to lessons, keyword-monitoring auto-replies, event reminders, and more.

🚀 **Ready-to-import n8n template**: [Auto-approve Skool members with GPT-4o AI screening](https://n8n.io/workflows/14392-auto-approve-skool-community-members-with-gpt-4o-ai-screening/)

#### Built by a solo community admin, for solo community admins

I run my own Skool community **alone** — no team, no virtual assistants, no community managers. Approving members, replying to posts, publishing courses, sending event reminders, exporting member data for follow-up — all the daily work of a 700+ member community.

This actor + its docs exist because **I needed them to keep up**. Every recipe in the docs is something I run in my own production. If a flow doesn't work for me with my actual time budget, it doesn't ship. If you're a solo founder or admin trying to grow a Skool community without burning out on repetitive admin clicks — this is built for you.

> **What's new in 0.3.44 (July 2026):** **`posts:getCommentsFull` rewritten — it no longer drives a browser.** Skool's comment endpoint *does* paginate, with `created-gt` (a microsecond cursor echoed back as `last`); the action now walks it instead of scraping the rendered page. **1095 of 1095 comments in ~5 seconds** on a real 1000-comment thread, down from ~326s. Because the data now comes from the API, every comment carries a **real 32-hex `id`** (stable across runs — safe as a dedup key), a **real `author.id`** (empty before, which collapsed whole threads onto one author downstream), an **ISO 8601 `createdAt`** (previously UI labels like `"17h"`), and `parentId` threading taken from the data model rather than inferred from indentation. `postSlug` is no longer required. It also **fails loud**: if the read comes back materially shorter than the post's own `commentCount`, the action returns `SCRAPE_INCOMPLETE` instead of a short list, so an incomplete read can never be mistaken for a quiet thread. `getAllComments()` in the library shares the same walk — it used to return ~35 of 1095 despite its name.
>
> **What's new in 0.5.0 (Sept 2026):** `members:remove` — remove an active member the way Skool's **Remove from group** button does. Not a ban: the member shows up under *Churned* and can rejoin through your normal join flow. Built for clean-ups ("remove everyone who never participated"); verified on 1,724 removals in a production group. Also: the Console action dropdown labels were off by one from `system:debug` down — every action now shows its own title.

> ⚠️ **`members:export` needs an email-verified `client_id` since 30 June 2026.** Skool reclassified the bulk CSV export as a *sensitive action*: the request must carry a `client_id` that has been verified with a code Skool emails you, and that verification **lapses every ~2 days**. Builds from 0.3.34 forward send the `client_id`; verifying it is on your side. Full runbook, including how to automate the re-verification from a programmatic inbox: **[Fix members:export — 401 email verification required](https://skool-api.cristiantala.com/recipes/fix-members-export-email-verification/)**.

> **What's new in 0.3.27 (May 2026):** `members:export` — the bulk CSV export the Skool admin "Export" button uses, now scriptable. **The only reliable way to get member emails on Skool** (the SSR member list returns `email:""` and `GET /users/{id}` masks third-party emails even for owners). Returns CSV with `Email, JoinedDate, Question1-3, Answer1-3, Tier, LTV` — the foundation of the CRM-sync pipeline. ([recipe](https://skool-api.cristiantala.com/recipes/export-skool-members-csv/)) · `members:list` paginated correctly (`{all:true}` returns all 600+ instead of first page) · `posts:update` now sends the flat body shape Skool actually persists (the wrapped shape returns `200 OK` but silently no-ops — caught via UI capture). Earlier 0.3.x: `files:uploadFile` + `classroom:updateResources` for lesson attachments (privacy:1 trap covered), `classroom:updateCourse` read-then-write (preserves `privacy`/`minTier`/`amount`), structured error hints (`MISSING_CATEGORY`, `TITLE_TOO_LONG`, `INSUFFICIENT_TIER`), never-throw guarantees, quality-check-safe defaults to prevent Apify's `UNDER_MAINTENANCE` flag.

### What makes this different

- **Read AND Write** — Create posts, edit comments, approve pending members, build entire courses (modules, folders, lessons), upload covers, edit Auto DM. Not just scraping.
- **Fast cookie-based auth** — Login once via Playwright (~10s), reuse cookies for ~3.5 days (~2s per call after that). Or pass email + password every call (slower, simpler).
- **Nested comments** — Full comment trees with replies, not flat lists.
- **User mentions** — Tag users with `[@Name](obj://user/{id})`.
- **Markdown → Skool TipTap** — Built-in zero-deps converter for course bodies. Handles headings, bold/italic/code/links, blockquote callouts, bullet/ordered lists, code blocks, and simple tables.
- **AI-agent friendly** — Clean `action:operation` format, structured failure payloads with recovery `hint`, perfect for **n8n**, **Make.com**, **OpenClaw**, **Claude**, **ChatGPT plugins**, **LangChain agents**, and any LLM tool-calling layer.
- **Production-tested** — Powers `cagala-aprende-repite` (Cágala, Aprende, Repite) with 700+ members and live newsletter / classroom / member pipelines.

### How much does it cost?

| Scenario | Estimated cost |
|----------|---------------|
| Login (once every ~3.5 days) | ~$0.02 (Playwright) |
| List 1 page of posts (~32 posts) | ~$0.005 |
| Get comments on a post (REST, max ~35) | ~$0.005 |
| Get ALL comments via Playwright scroll | ~$0.05 scrape fee + results |
| Create a post | ~$0.01 (write fee) + ~$0.005 |
| Reply to a comment | ~$0.01 (write fee) + ~$0.005 |
| Approve 10 pending members | ~$0.10 (write fees) + ~$0.005 |

Platform compute costs (~$0.002/run) included. Write operations have premium pricing.

### Recommended Flow (Fast)

#### Step 1: Login once — get cookies

```json
{
  "action": "auth:login",
  "email": "your@email.com",
  "password": "your-password",
  "groupSlug": "your-community"
}
```

**Output:**

```json
{
  "success": true,
  "cookies": "auth_token=eyJ...; client_id=abc...; aws-waf-token=xyz...",
  "expiresAt": "2026-03-30T06:00:00.000Z",
  "expiresInDays": 3.5,
  "note": "Pass this cookies string in subsequent calls to skip Playwright login."
}
```

#### Step 2: Use cookies for all operations (fast, ~2s each)

```json
{
  "action": "posts:list",
  "cookies": "auth_token=eyJ...; client_id=abc...; aws-waf-token=xyz...",
  "groupSlug": "your-community",
  "params": { "page": 1 }
}
```

#### Step 3: When cookies expire (~3.5 days) → login again

If you get an error about expired auth, simply run `auth:login` again.

#### Alternative: email + password every time (slower)

You can skip Step 1 and pass email + password directly in any action. This uses Playwright every time (~10s instead of ~2s), but it's simpler for one-off operations.

```json
{
  "action": "posts:list",
  "email": "your@email.com",
  "password": "your-password",
  "groupSlug": "your-community",
  "params": { "page": 1 }
}
```

### Input Reference

| Field | Required | Description |
|-------|----------|-------------|
| `action` | Always | What to do: `auth:login`, `posts:list`, `posts:createComment`, etc. |
| `groupSlug` | Always | Community slug from URL: `skool.com/{slug}` |
| `cookies` | Option A | Cookie string from `auth:login`. Fast, no browser. |
| `email` | Option B | Skool email. Uses Playwright, slower. Required for `auth:login`. |
| `password` | Option B | Skool password. Required for `auth:login`. |
| `params` | Varies | Action-specific parameters (see below). |

### Actions Reference

#### Authentication

| Action | Auth | Description |
|--------|------|-------------|
| `auth:login` | email + password | Login via Playwright, returns cookies for reuse. Run once every ~3.5 days. |

#### Posts (read)

| Action | Params | Description |
|--------|--------|-------------|
| `posts:list` | `page?` (default 1), `sortType?` | List posts from community feed |
| `posts:get` | `postId` | Get single post by ID |
| `posts:getComments` | `postId` | Get comment tree (REST, fast ~2s, capped at ~35 comments due to Skool API limit) |
| `posts:getCommentsFull` | `postId` | Get **ALL** comments in a thread, replies included, with real ids/author ids/ISO dates. Bypasses Skool's ~35 comment REST cap. **$0.05 scrape fee** per invocation. ~5s for 1000+ comments |

#### Posts (write — $0.01 per operation)

| Action | Params | Description |
|--------|--------|-------------|
| `posts:create` | `title`, `content`, `labelId?`, `notifyAll?`, `pollId?`, `attachmentId?` | Create new post — optionally emailing every member, with a poll and/or an image |
| `posts:createPoll` | `options` (string\[], 2+) | Create a poll, then pass the returned `pollId` to `posts:create` |
| `posts:update` | `postId`, `title?`, `content?` | Edit post or comment |
| `posts:delete` | `postId` | Delete post or comment |
| `posts:pin` | `postId` | Pin post to top |
| `posts:unpin` | `postId` | Unpin post |
| `posts:vote` | `postId`, `vote` (`"up"` or `""`) | Like or unlike |
| `posts:createComment` | `content`, `rootId`, `parentId` | Reply to post or comment |

##### Emailing every member (`notifyAll`)

`notifyAll: true` is the "Send email to all members" toggle. Two things to know before using it:

- **It's irreversible, and admin-only.** Skool's own UI cannot turn it on after a post exists — it's available only at creation time. That's the whole reason this option exists.
- **A `200` does not mean the email was sent.** Skool ignores an unrecognised notify value and returns success anyway, so this is a strict boolean: passing the string `"true"` is rejected rather than guessed at. Confirm delivery in an inbox — the broadcast email's footer reads *"Don't want admin broadcast emails"*.

##### Polls and images

A poll is its own object, so it takes two calls — and **a poll has no title**: the question is the post's `content`.

```jsonc
// 1) create the poll
{ "action": "posts:createPoll", "params": { "options": ["Option A", "Option B"] } }
// 2) attach it (pollId from step 1)
{ "action": "posts:create",
  "params": { "title": "Weekly question", "content": "What's blocking you?",
              "labelId": "...", "pollId": "..." } }
```

For an image, upload it with `files:uploadImage` and pass the returned file id as `attachmentId` (a single id, not a list).

#### Members (read)

| Action | Params | Description |
|--------|--------|-------------|
| `members:list` | `page?` | List active members |
| `members:pending` | — | List pending approval requests |

#### Members (write — $0.01 per operation)

| Action | Params | Description |
|--------|--------|-------------|
| `members:approve` | `memberId` | Approve pending member |
| `members:reject` | `memberId` | Reject pending member |
| `members:remove` | `memberId` | Remove an active member — same call as Skool's "Remove from group": no ban, shows as Churned, can rejoin |
| `members:ban` | `memberId` | Ban member |

#### Classroom — courses, folders, pages, TipTap bodies

UI Skool renders 3 levels: **Course** (top tile) → **Page direct** (`unit_type:"module"`) OR **Folder** (`unit_type:"set"`) → **Page**. Modules nested in modules are invisible.

| Action | Params | Description |
|--------|--------|-------------|
| `classroom:listCourses` | — | List all courses in the group |
| `classroom:getTree` | `courseId` | Recursive tree for a top-level course |
| `classroom:createCourse` | `title`, `desc?`, `coverImage?`, `coverImageFile?`, `privacy?` (0/1/2/3/4), `minTier?`, `amount?` (USD cents, for privacy=3), `drip?` `{enabled,days}` (for privacy=4), `affiliateCommissionEligible?`, `state?` | Create top-level course |
| `classroom:createFolder` | `parentCourseId`, `title`, `state?` | Create folder (`unit_type:"set"`) inside a course |
| `classroom:createPage` | `courseId`, `parentId` (course or folder id), `title`, `state?` | Create page (`unit_type:"module"`) |
| `classroom:setBody` | `pageId`, `title`, `bodyMarkdown?` OR `bodyRaw?`, `videoId?`, `transcript?` | Set page body — `bodyMarkdown` is converted to TipTap `[v2]` automatically |
| `classroom:updateCourse` | `courseId`, `title?`, `desc?`, `coverImage?`, `coverImageFile?`, `privacy?` (0/1/2/3/4), `minTier?`, `amount?`, `transcript?`, `videoId?` | Read-then-write update. Any field you don't pass is preserved automatically (the actor reads the course first, merges, then writes). Pass `privacy`/`minTier`/`amount` to change them; omit them to keep the current values. Avoids the Skool quirk where partial PUTs reset `privacy` to 0. |
| `classroom:deleteUnit` | `id` | Delete course / folder / page (cascades children) |
| `classroom:updateResources` | `courseId`, `pageId`, `resources` (array of `{title, file_id}`, max ~34 chars per title) | Replace the **Resources** list shown under a lesson page (the *Add resource file* UI). Pass `[]` to clear all. `file_id` MUST come from `files:uploadFile` (not `uploadImage`) — Skool rejects `privacy:0` files with `400 invalid file ... privacy: 0`. Skool replaces the full list (no patch semantics) on every call. |

`privacy` values: `0` Open · `1` Level unlock (uses `minTier` as gamification level) · `2` Private · `3` Buy now (requires `amount`, may use `minTier` as paid tier override) · `4` Time unlock (requires `drip`).

`bodyMarkdown` accepts standard markdown: headings, **bold**, *italic*, `code`, [links](url), `> blockquotes` (lists nested inside callouts are flattened to `• ` so the callout border stays cohesive — Skool only borders `paragraph` children), bullet/ordered lists, ` ``` ` code blocks, simple tables. Output is the literal `[v2]<JSON>` Skool's `desc` field expects. Zero deps; converter ported in-tree.

#### Files — image + private file upload

| Action | Params | Description |
|--------|--------|-------------|
| `files:uploadImage` | `bufferBase64` OR `imageUrl` | Upload a public image; returns `{coverImageUrl, coverImageFile}` ready for `classroom:createCourse`. Re-encoded server-side (PNG/WebP → JPG). Recommended dimension: 1460×752 |
| `files:uploadFile` | `filename` + (`bufferBase64` OR `fileUrl` OR `text`), optional `contentType` (default `application/octet-stream`) | Upload a **private file** (PDF, JSON, ZIP, etc.) with `privacy:1`. Returns `{fileId, contentType, fileName}`. Pair with `classroom:updateResources` to attach to a lesson |

The two file actions differ by privacy: `uploadImage` registers a `public-read` file (used as a cover URL); `uploadFile` registers a `private` file (signed download URL generated on demand by Skool). Don't try to pass an `uploadImage` file id into `updateResources` — Skool rejects it.

#### Groups — config + Auto DM

| Action | Params | Description |
|--------|--------|-------------|
| `groups:get` | `slug` | Group object (id, metadata) — uses Next.js SSR endpoint |
| `groups:setAutoDM` | `message` | Update Auto DM new members template. Tokens: `#NAME#`, `#GROUPNAME#`. UI limit: 300 chars |

### Content Format

Posts and comments use **plain text**, NOT HTML.

```
CORRECT: "Hello world! Great post."
WRONG:   "<p>Hello world! Great post.</p>"
```

#### Mentioning Users

Tag users in posts and comments:

```
[@Display Name](obj://user/{userId})
```

Example: `Hey [@John Smith](obj://user/abc123def456...)! Welcome.`

Get user IDs from `members:list`.

### Important: Posts = Comments

In Skool, posts and comments are the **same object**.

- **Reply to a post:** `rootId = postId`, `parentId = postId`
- **Reply to a comment (nested):** `rootId = postId`, `parentId = commentId`
- **Edit a comment:** use `posts:update` with the comment's ID
- **Delete a comment:** use `posts:delete` with the comment's ID

There is no `comments:` namespace. Everything is `posts:`.

### Example Workflows

#### Auto-approve pending members

```
1. auth:login → save cookies
2. members:pending → get list of pending members
3. For each: members:approve with memberId
```

#### Reply to unanswered posts

```
1. auth:login → save cookies
2. posts:list → filter by commentCount === 0
3. For each: posts:createComment with rootId = postId, parentId = postId
```

#### Create post with user mention

```json
{
  "action": "posts:create",
  "cookies": "...",
  "groupSlug": "my-community",
  "params": {
    "title": "Weekly Update",
    "content": "Great work this week [@John Smith](obj://user/abc123...)! Keep it up.",
    "labelId": "category-id"
  }
}
```

#### Get comment thread for a post (REST, max ~35 comments)

```json
{
  "action": "posts:getComments",
  "cookies": "...",
  "groupSlug": "my-community",
  "params": { "postId": "32-char-hex-post-id" }
}
```

Returns nested tree:

```json
[
  {
    "id": "comment-1",
    "content": "Great post!",
    "replies": [
      { "id": "reply-1", "content": "Thanks!", "replies": [] }
    ]
  }
]
```

#### Get ALL comments in a thread (no ~35 cap)

For threads with hundreds of comments (welcome threads, popular posts, AMAs), a single REST call caps at ~35. Use `posts:getCommentsFull` to get the whole thread — verified at **1095/1095 on a real 1000-comment welcome thread, in about 5 seconds**. **$0.05 scrape fee per invocation.**

It works by paging Skool's own comment endpoint with the cursor its UI uses (`created-gt`, a microsecond timestamp returned as `last`). Because the data comes from the API rather than the rendered page, every comment carries what you actually need downstream:

- **Real comment `id`** (32-hex), stable across runs — safe as a dedup key.
- **Real `author.id`**, so comments don't collapse onto one author when you insert them.
- **ISO 8601 `createdAt`**, not UI labels like `"17h"` or `"Mar 7"`.
- **`parentId` threading** taken from the data model, not inferred from layout.

Only `postId` is required; `postSlug` is accepted and ignored (it was needed back when this drove a browser).

If the read comes back materially shorter than the post's own comment count, the action fails with `SCRAPE_INCOMPLETE` rather than returning a short list — an incomplete read can never be mistaken for an empty thread. Optional params: `strict` (default true), `expectedCount`, `pageSize`, `maxPages`.

```json
{
  "action": "posts:getCommentsFull",
  "cookies": "...",
  "groupSlug": "my-community",
  "params": {
    "postId": "32-char-hex-post-id",
    "postSlug": "url-slug-of-the-post"
  }
}
```

Returns flat array of all comments (top-level + nested replies):

```json
[
  { "id": "scraped-0", "content": "Hi everyone! I'm Jane...", "author": { "firstName": "Jane", "lastName": "Doe" }, "isReply": false },
  { "id": "scraped-1", "content": "Welcome Jane!", "author": { "firstName": "Admin" }, "isReply": true },
  ...
]
```

**When to use which:**

- `posts:getComments` — fast, free, returns first ~35 comments. Use for new threads and most cases.
- `posts:getCommentsFull` — slow + paid, returns ALL (replies included). Use only when the thread has >35 comments and you need full coverage.

#### Attach a downloadable file to a lesson page (Resources)

Two-step flow: upload the file with `privacy:1`, then attach it via `updateResources`. The same `file_id` can be reused across pages.

```json
// Step 1 — upload the JSON workflow (or PDF / ZIP / etc.)
{
  "action": "files:uploadFile",
  "cookies": "...",
  "groupSlug": "my-community",
  "params": {
    "filename": "my-workflow.json",
    "contentType": "application/json",
    "text": "{\n  \"name\": \"My workflow\",\n  \"nodes\": [],\n  \"connections\": {}\n}"
  }
}
// → { "fileId": "63a16fa2d8f3472d8609e469e13a6bcc", "fileName": "my-workflow.json", "contentType": "application/json" }
```

```json
// Step 2 — attach to a lesson under "Resources"
{
  "action": "classroom:updateResources",
  "cookies": "...",
  "groupSlug": "my-community",
  "params": {
    "courseId": "TOP_LEVEL_COURSE_ID",
    "pageId": "LESSON_PAGE_ID",
    "resources": [
      { "title": "W-01 · Auto-approve members", "file_id": "63a16fa2d8f3472d8609e469e13a6bcc" }
    ]
  }
}
```

Pass `"resources": []` to clear all attachments. Skool replaces the entire list on every call (no patch semantics). `title` is capped to ~34 chars by the UI. Use `bufferBase64` instead of `text` for binary files.

### Output

#### Post object

```json
{
  "id": "32-char-hex",
  "title": "Post Title",
  "content": "Plain text content",
  "author": { "id": "...", "firstName": "John", "lastName": "Smith", "slug": "john-smith" },
  "createdAt": "2026-03-26T18:00:00Z",
  "likes": 5,
  "commentCount": 12,
  "isPinned": false,
  "url": "https://www.skool.com/community/post-slug"
}
```

#### auth:login output

```json
{
  "success": true,
  "cookies": "auth_token=...; client_id=...; aws-waf-token=...",
  "expiresAt": "2026-03-30T06:00:00Z",
  "expiresInDays": 3.5
}
```

### Error Handling

**Starting in v0.3.0**, runs **NEVER** exit with `exit_fail`. Every error — recognized or not — becomes a structured `{success:false}` payload pushed to the dataset, and the run terminates with `SUCCEEDED`. This is intentional: an `exit_fail` feeds Apify's heuristic that flips the actor's `notice: UNDER_MAINTENANCE` flag, which then breaks production workflows silently. The cost of structured failures in the dataset is much lower than a flag flip.

Your integration code MUST inspect `dataset[0].success` before assuming a result.

#### Failure payload shape (dataset)

```json
{
  "success": false,
  "action": "posts:createComment",
  "error": "post not found: 3bc910b1",
  "errorCode": "NOT_FOUND",
  "errorCategory": "not_found",
  "statusCode": 404,
  "retryable": false,
  "hint": "Verify the ID provided in params. If it was valid before, the resource may have been deleted."
}
```

#### Error categories

| `errorCategory` | Meaning | Typical `retryable` | What to do |
|---|---|---|---|
| `input_validation` | Missing/bad params (no `postId`, no `action`, etc.) | `false` | Fix the actor input and rerun |
| `auth_error` | Login failed, captcha, bad password, `AUTH_ERROR`, `WAF_EXPIRED` | `true` | Re-run `auth:login` to get fresh cookies |
| `not_found` | `NotFoundError` from Skool (post/member not found) | `false` | Verify the ID; resource may have been deleted |
| `rate_limited` | 429 from Skool | `true` | Back off and retry after a few minutes |
| `skool_api_error` | 4xx from Skool other than above (e.g. "cannot update to same role", missing labelId) | usually `false` | Inspect `error` and fix params; if 5xx, retry later |
| `scraping_error` | `BuildIdStaleError` when Skool dashboard HTML changed | `true` | Re-run `auth:login` to refresh `buildId` |
| `network_error` | Fetch timeout, abort, DNS failure, connection reset (`AbortError`/`TimeoutError`/`ECONN*`) | `true` | Transient. Retry after 30-60s. If it persists for >5 min, Skool or your network is down |
| `unknown_error` | Anything not classified above (real bug). Includes `stack` field for diagnosis | `true` | Inspect the `stack` in the payload + Apify run logs. Open an issue if reproducible |

#### Recognising a failure in your caller

```javascript
const items = await fetchDatasetItems(runId);
const first = items[0];

if (first.success === false) {
  console.error(`[${first.errorCategory}/${first.errorCode}] ${first.error}`);
  if (first.retryable) {
    // back off, refresh auth, or retry — based on errorCategory
  } else {
    // surface to user, adjust input
  }
  return;
}
// success path
```

#### Common errors → how to fix

| Error message / `errorCode` | Cause | Solution |
|-------|-------|----------|
| `INPUT_VALIDATION` "Missing required params.postId" | Missing field | Add the field in `params` |
| `AUTH_ERROR` "Login failed — still on login page after 30s" | Wrong password, WAF challenge, or Skool rate-limited the login | Verify credentials; try again from a browser first |
| `WAF_EXPIRED` | Cookies older than ~3.5 days. Since 0.4.0 this also covers the AWS WAF challenge, where Skool answers with **HTTP 202 and an empty body** instead of a 403 — before, that surfaced as a confusing `AUTH_ERROR` about a missing `buildId` | Re-run `auth:login`. The message states whether Skool itself is fine: it re-checks the same page without cookies, so it can tell "your session is dead" apart from "Skool is down or blocking you" |
| `BUILDID_STALE` | Skool deployed a new dashboard, so the cached buildId no longer resolves. Since 0.4.4 this fires only when Skool answers the SSR request with its **HTML** 404 page — the signature of a buildId failure | Re-run `auth:login`. If the message says the body looked like a Cloudflare interstitial instead, that is a transient block on the egress IP: wait ~30s and retry the same action first |
| `GROUP_NOT_FOUND` | The SSR route resolved (Skool answered with **JSON**) and the group path did not. Before 0.4.4 this was reported as `BUILDID_STALE`, which sent callers into an `auth:login` loop that could not fix it | Re-authenticating will NOT help. Check: (1) `groupSlug` is only the segment after `skool.com/`, (2) the case matches — slugs are lowercase and case-sensitive, (3) the authenticated account can open `https://www.skool.com/<groupSlug>` in a browser. A private group you have not joined answers exactly like one that does not exist |
| `INVALID_GROUP_SLUG` | `groupSlug` cannot be a slug: a pasted URL, a path, a query string, whitespace or empty. Raised before authenticating, so no login is spent | Pass the bare slug: for `https://www.skool.com/my-community`, pass `my-community`. The error names the value it extracted from what you sent |
| `NOT_FOUND` | Invalid `postId` / `memberId` | Double-check the ID source |
| `POST_NOT_FOUND` | Post or comment id that Skool cannot resolve. Since 0.4.0 this is named instead of falling through as a generic `HTTP_ERROR` | The hint tells you which case you hit. **If the id is shorter than 32 chars you copied it from a URL**: the `?p=` fragment is only an 8-char prefix, and completing it by hand never resolves. Take the full id from the `id` field of `posts:list`, `posts:get` or `posts:getCommentsFull` |
| `RATE_LIMIT` | Hit 20-30 writes/min ceiling | Wait 60-120s, then retry |
| `SKOOL_API_ERROR` (400) "cannot update to same role" | Member already has that role | No-op: skip |
| `SKOOL_API_ERROR` (422) "must select category" | Community requires `labelId` | Pass `labelId` in params |

### Integration Examples

#### n8n Workflow

1. **HTTP Request node** → `auth:login` → save cookies to variable
2. **HTTP Request node** → `posts:list` with cookies → process posts
3. **IF node** → filter unanswered posts
4. **HTTP Request node** → `posts:createComment` with cookies

#### Apify API (JavaScript)

```javascript
// Login once
const loginRun = await fetch('https://api.apify.com/v2/acts/cristiantala~skool-all-in-one-api/runs?token=YOUR_TOKEN', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ action: 'auth:login', email: '...', password: '...', groupSlug: '...' })
}).then(r => r.json());

// Get cookies from dataset
const items = await fetch(`https://api.apify.com/v2/actor-runs/${loginRun.data.id}/dataset/items?token=YOUR_TOKEN`).then(r => r.json());
const cookies = items[0].cookies;

// Use cookies for fast operations
const listRun = await fetch('https://api.apify.com/v2/acts/cristiantala~skool-all-in-one-api/runs?token=YOUR_TOKEN', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ action: 'posts:list', cookies, groupSlug: '...', params: { page: 1 } })
}).then(r => r.json());
```

### 📚 Tutorials, recipes & docs

Full reference + production-grade recipes: **[github.com/ctala/skool-api-docs](https://github.com/ctala/skool-api-docs)**

- [Getting Started](https://skool-api.cristiantala.com/docs/getting-started/) — first call, cookies, auth flow
- [Authentication](https://skool-api.cristiantala.com/docs/authentication/) — WAF token rotation + the `x402-payment-required` "fake error" explained
- [Posts & Comments](https://skool-api.cristiantala.com/docs/posts/) · [Members](https://skool-api.cristiantala.com/docs/members/) · [Classroom](https://skool-api.cristiantala.com/docs/classroom/) · [Files](https://skool-api.cristiantala.com/docs/files/) · [Groups](https://skool-api.cristiantala.com/docs/groups/)
- [Error handling](https://skool-api.cristiantala.com/docs/error-handling/) — full `errorCode` catalog + retry patterns
- [AI Agents integration](https://skool-api.cristiantala.com/docs/agents/) — Claude / OpenAI / Gemini function calling, MCP server, LangChain Tool, **Claude Code Skill**

**Recipes** (copy-paste integrations):

- [Auto-approve members with n8n + GPT-4o](https://n8n.io/workflows/14392-auto-approve-skool-community-members-with-gpt-4o-ai-screening/) — **published n8n template, ready to import**
- [Reply to unanswered posts automatically](https://github.com/ctala/skool-api-docs/blob/main/recipes/reply-unanswered-posts.md)
- [Publish a course from markdown files](https://github.com/ctala/skool-api-docs/blob/main/recipes/publish-course-from-markdown.md)
- [Auto DM new members](https://github.com/ctala/skool-api-docs/blob/main/recipes/auto-dm-new-members.md)
- [Batch update course covers safely](https://github.com/ctala/skool-api-docs/blob/main/recipes/batch-update-course-covers.md)
- [Mirror your newsletter as a Skool post](https://github.com/ctala/skool-api-docs/blob/main/recipes/newsletter-to-skool-post.md)

⭐ **Get notified of new releases**: [Watch the docs repo](https://github.com/ctala/skool-api-docs) — every release ships with a `CHANGELOG.md` entry.

### Roadmap

- \[x] Posts CRUD (create, read, update, delete)
- \[x] Comments with nesting (reply to posts and comments)
- \[x] Members (list, pending, approve, reject, ban, batchApprove)
- \[x] User mentions
- \[x] Cookie-based fast auth
- \[x] Courses (classroom) — read, create, update, delete, markdown→TipTap converter
- \[x] File uploads (cover images, group icon/cover)
- \[x] Auto DM new members (groups:setAutoDM)
- \[x] Group description (read + update)
- \[ ] Events — list, create, RSVP
- \[ ] Analytics — engagement, revenue, member growth
- \[ ] Chat / DMs
- \[ ] Search
- \[ ] Apply form questions (read + update)
- \[ ] Discovery keywords (read + update)
- \[x] "Send email to all members" toggle on `posts:create` — shipped in 0.3.7 (`notifyAll`)
- \[x] Polls (`posts:createPoll`) and image attachments (`attachmentId`) — shipped in 0.3.7
- \[ ] YouTube embeds (`videoIds` needs Skool's internal video id, not a URL)

### FAQ — Skool API Common Questions

#### Does Skool.com have an official API?

No. Skool does not provide a public API. This actor reverse-engineers Skool's internal endpoints (Next.js SSR for reads, `api2.skool.com` for writes) and exposes them as a clean, AI-friendly API. It's the production-ready alternative to scraping with Playwright or manually copy-pasting from the Skool UI.

#### Can I automate my Skool community without coding?

Yes. The actor is designed for **n8n**, **Make.com**, **Zapier**, and **Pipedream** — all no-code platforms. There's a ready-to-import n8n template for auto-approving members with AI screening. Just connect via HTTP Request nodes (3-4 nodes per workflow). No backend needed.

#### How does the Skool API handle authentication?

Two options: (1) **Email + password** every call (uses Playwright, ~10s per run, simpler), or (2) **Cookie reuse** (run `auth:login` once → save the cookies string → pass in subsequent calls for ~2s response time, valid ~3.5 days). Cookies expire because of Skool's WAF token rotation. The actor exposes `auth:login` so you can re-authenticate programmatically when needed.

#### Can I read AND write to Skool, or only read?

Both. Unlike scrapers, this actor performs full CRUD operations: create posts, edit comments, approve members, ban users, publish entire courses (classroom), upload covers, set Auto DM, update group descriptions. The write operations require admin or moderator rights in the target community.

#### How many Skool comments can I retrieve from a thread?

A single call caps at ~35 comments per thread (25 head + 10 tail). The parameter that gets past it is `created-gt` — a microsecond cursor returned as `last` on each response — and `posts:getCommentsFull` uses it to walk the entire thread, replies included, in about 5 seconds for 1000+ comments. Costs $0.05 per invocation.

#### How does the Skool API integrate with AI agents (Claude, ChatGPT, LangChain)?

Every action follows a clean `namespace:operation` format ideal for **function calling** / **tool use**. The structured failure payloads include `errorCode`, `errorCategory`, and a recovery `hint` — agents can self-correct on errors. Use cases: AI member screening, auto-classification of posts, AI-generated welcome replies, automated content moderation. Compatible with **OpenAI function calling**, **Anthropic tool use**, **LangChain Tool**, **Google Gemini**, **MCP**.

#### Can I use this Skool API with multiple communities?

Yes. Each call takes a `groupSlug` parameter, so you can operate across multiple Skool communities where you're admin. Common pattern: one set of cookies per admin account, then switch `groupSlug` per call to target different communities.

#### What's the cost of running the Skool API?

See pricing table at the top of this README. Roughly: $0.005 per dataset result, $0.01 per write operation (create/update/delete/approve/etc.), $0.05 per Playwright scrape operation, and $0.01 actor start fee per run. A typical "auto-approve 10 pending members" run costs ~$0.10. Cookie auth means you only pay the Playwright fee once every 3.5 days.

#### Does the Skool API support Skool's user mentions?

Yes. Posts and comments support the format `[@Display Name](obj://user/{userId})`. Get user IDs from `members:list`. This renders as a clickable mention in Skool, exactly like the UI.

#### Can I publish Skool courses programmatically?

Yes. The `classroom:*` namespace lets you create courses (top-level tiles), folders (sections), and pages (lessons). The `classroom:setBody` action takes your markdown in **`bodyMarkdown`** (that exact parameter name) and converts it to Skool's TipTap JSON format internally — including headings, lists, code blocks, callouts (blockquotes), bold/italic/code, and tables. You can also attach downloadable resources via `files:uploadFile` + `classroom:updateResources`.

#### What happens if Skool changes their API?

The actor uses `skool-js`, a TypeScript library that is actively maintained. When Skool deploys a new version (~weekly), the `buildId` changes — the actor handles this automatically by refreshing from the homepage. WAF token expiration is also handled (auto-retry with re-auth). Breaking changes are documented in the `What's new` section of this README and the [docs repo CHANGELOG](https://github.com/ctala/skool-api-docs).

### Technical Details

- Built on `skool-js` TypeScript library
- Reads use Next.js SSR data endpoints (fast, no browser)
- Writes use `api2.skool.com` REST endpoints
- Playwright only for `auth:login` (cookie extraction)
- Rate limiter: 60 reads/min, 30 writes/min
- Auto-retry on transient errors with exponential backoff

### Reliability — Why Apify's `UNDER_MAINTENANCE` flag doesn't apply here

Apify ships every actor through a **daily Automated Quality Check**: it runs the actor with the input schema's `prefill`/`default` values and expects `SUCCEEDED + non-empty dataset + under 5 minutes`. If the actor fails 2+ times in 3 days, Apify auto-flags it as `UNDER_MAINTENANCE` and every call returns `x402-payment-required` until the flag is cleared.

This actor is configured to **always pass** that check:

- `default action = system:health` — no auth, no Skool calls, deterministic 1-item dataset, ~2s total. Apify's daily probe never fails because of credentials, rate-limits, or Skool downtime.
- All errors are caught and pushed as structured `{success:false}` payloads. Runs always end `SUCCEEDED` regardless of outcome — Apify never sees an `exit_fail` from this actor.
- `system:health` is also a one-call diagnostic for your own monitoring.

If you ever observe `x402-payment-required`, it's a transient Apify-side flag and will clear when the actor's owner runs `PUT /v2/acts/{id}` with `{"notice": null}`. Open an issue and I'll clear it within minutes.

# Actor input Schema

## `action` (type: `string`):

What to do.

auth:login → Login with email+password, returns cookies for reuse (~10s, Playwright)
All other actions → Use cookies (fast, ~2s) or email+password (slow, ~10s)

Recommended flow:

1. Run auth:login once → save cookies from output
2. Pass cookies in all subsequent runs (fast, no browser)
3. When you get 403 error → run auth:login again (~every 3.5 days)

## `email` (type: `string`):

Required for auth:login. Optional for other actions if you provide cookies instead.

## `password` (type: `string`):

Required for auth:login. Optional for other actions if you provide cookies instead.

## `cookies` (type: `string`):

Cookie string from a previous auth:login call. Using cookies skips Playwright login entirely (~2s vs ~10s). Format: auth_token=xxx; client_id=yyy; aws-waf-token=zzz

## `groupSlug` (type: `string`):

The community slug from the URL: skool.com/{slug}

## `params` (type: `object`):

Parameters for the selected action.

Common params:
• page (number) — page number for list actions
• postId (string) — 32-char hex post ID
• memberId (string) — 32-char hex member ID
• title (string) — post title (plain text)
• content (string) — post/comment body (plain text, NOT HTML)
• labelId (string) — category ID (required in some communities)
• rootId (string) — for comments: always the original post ID
• parentId (string) — for comments: post ID (top-level) or comment ID (nested reply)
• vote (string) — 'up' to like, '' to unlike

Mentions: [@Name](obj://user/{userId})

posts:getCommentsFull (optional):
• postSlug (string) — accepted but ignored; postId alone is enough
• strict (boolean) — default true; fail with SCRAPE_INCOMPLETE instead of returning a short list
• expectedCount (number) — skips the extra lookup of the post's comment count
• pageSize (number) — default 25; comments per page (Skool caps around 25-30)
• maxPages (number) — default 200; safety bound on the pagination loop

## Actor input object example

```json
{
  "action": "posts:list",
  "groupSlug": "my-community",
  "params": {
    "page": 1
  }
}
```

# Actor output Schema

## `results` (type: `string`):

Dataset items from the requested action. Shape depends on the action: arrays of posts/members/courses for list endpoints, single objects for create/get, or `{success:false, errorCode, errorCategory, retryable, hint}` payloads for any error.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "action": "system:health",
    "groupSlug": "skool"
};

// Run the Actor and wait for it to finish
const run = await client.actor("cristiantala/skool-all-in-one-api").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "action": "system:health",
    "groupSlug": "skool",
}

# Run the Actor and wait for it to finish
run = client.actor("cristiantala/skool-all-in-one-api").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "action": "system:health",
  "groupSlug": "skool"
}' |
apify call cristiantala/skool-all-in-one-api --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,cristiantala/skool-all-in-one-api"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/g2VGrINIS7pb8t2bs/builds/DNSqkhVhdVpTpxdNz/openapi.json
