# Commune Course Library API — Courses, Lessons & Teachers

*Formerly called the "Library API"; URLs unchanged.*

**Revision: 2026-10-22** (`exclude_from_origins`, added to every lesson object on 2026-10-21, removed again: the content team dropped the sheet column) · Previous revisions: 2026-09-25 (renamed from "Library API"; lesson resource title/link fields added), 2026-09-15

> **See also:** [`origins-api.md`](./origins-api.md) covers customer, subscription,
> transaction and offer data for the Kajabi migration. This document covers
> **course and lesson content** — titles, descriptions, images, teachers, and
> the Kajabi `product_id` that joins content to the billing data in the other
> doc. They're separate APIs with separate auth models; read on for why.

- **Base URL:** `https://communeapi.com`
- **Prefix:** `/api/library`
- **Format:** JSON. Every public endpoint is `GET`.
- **Auth:** **None required today.** This is a public, read-only catalog, and
  anonymous requests are always served. There is no rate limit beyond "be
  reasonable."
- **API keys (optional, logged only):** a caller Commune has issued a key to
  sends it as `Authorization: Bearer <key>`. Keys are configured per consumer
  on the server as `LIBRARY_API_KEY_<NAME>` environment variables. Every
  `/api/library/*` request is currently **logged** as keyed or unkeyed
  (method, URL, client IP, user agent, referrer) and then served exactly the
  same either way: a missing, malformed or unknown key is never rejected. The
  log exists so Commune can see who calls the API before deciding whether to
  require a key; that decision hasn't been made. If you have a key, send it.
- **Signed-in browsers:** a browser signed into the Commune tools as an
  external-partner account (always), or as a guest-domain or email-code
  account *without* access to the Library Search tool, gets `403` on
  `/api/library/*`, because the server scopes those session types per tool.
  Send no `report_session` cookie and this never applies.
- **Search UI:** `https://communeapi.com/tools/library-search/` (Commune
  sign-in required, while the API itself is open). Its guide is
  [`../tools/library-search.md`](../tools/library-search.md).

## Which API do I want?

| You want | Use | Base URL | Auth | Reference |
|---|---|---|---|---|
| **Short-form social clips**: promo clips cut from course footage (1x1 / 4x5 / 9x16) and the Instagram archive (reels, images, carousels, quote cards), with their course and lesson links, teacher, tags and click-through link | **Short-Form Content API** | `https://communeapi.com/api/content/v1` | Required: `Authorization: Bearer <key>`, one key per partner | [`content-api.md`](./content-api.md) |
| **Courses, lessons and teachers**: titles, descriptions, images, runtimes, transcripts, video filenames, tags, the Kajabi `product_id` | **Course Library API** | `https://communeapi.com/api/library` | None required (an optional `Authorization: Bearer <key>` is logged, never refused) | [`library-api.md`](./library-api.md) |

The two join on course slug and Kajabi lesson id: a clip's `course` /
`lesson` filters take the Course Library API's `slug` / `lessonid`.

> **Renamed 2026-09-25 — display names only.** The Short-Form Content API
> was called the "Content API" (and its tool the "Content Library"); the
> Course Library API was called the "Library API". Every URL, route, file
> path, environment variable and key is unchanged: `/api/content/v1`,
> `/api/content-library`, `/api/library`, `/tools/content-library/`,
> `CONTENT_API_KEYS`, `LIBRARY_API_KEY_<NAME>`, `docs/api/content-api.md`,
> `docs/api/library-api.md`. Nothing on the caller's side needs to change.

---

## Why this is a separate document from `origins-api.md`

They're genuinely different systems, not one API split across two pages:

- **Different data.** Origins is Kajabi's legacy *billing* record set
  (customers, subscriptions, transactions, offers). This is the *content*
  catalog (courses and lessons) that has always lived in
  `business_intelligence.courses` / `.lessons`, maintained independently of
  the migration.
- **Different auth.** Origins requires a per-partner bearer key because it's
  PII and revenue data. This catalog is public: a key is optional and only
  logged (see *Auth* above), never required.
- **Different lifecycle.** Origins is scoped to the Origins migration and gets
  revised for that engagement. This API is consumed by other things (the
  onecommune.com site, internal tools) and evolves on its own schedule.

If you only need one thing from this whole page: **`product_id` on a course
is the same Kajabi product id that appears in `/api/origins/customers[].products`
context and in `offer_id`-adjacent product lists** — it's the join key between
this catalog and the billing side.

---

## What's in here

The Commune course library: **185 courses** and **4,822 lesson rows** (as of
the 2026-09-09 recompute), plus the **202 teachers** credited on them and the
**32 content tags** the lessons are classified with. Every figure in this
document moves as content is added: each list endpoint reports a live
`total`, and [`GET /api/library/meta`](#11-catalog-meta) carries the current
row and canonical counts with the timestamp they were computed at.

> **Counting lessons.** The row count is not the number of distinct
> classes. The same class legitimately appears more than once — a summit reusing
> a video, a course that ran live and later shipped self-paced, a yoga class in
> silent and "(with Music)" cuts. As of 2026-09-09 the raw catalog is **4,822
> rows, 4,318 of them canonical** (the citation for their class). Which number
> you want depends on the question, and the API answers both rather than
> leaving you to guess: see [Canonicalization](#canonicalization) — and note
> the **breaking change to `lesson_count_canonical` on 2026-09-03** described
> there.

A *course* is a product on onecommune.com (`21-Day Yoga Challenge`, `Ecstatic
Breathwork`). A *lesson* is one video inside a course. Every lesson knows its
course, and a course can list its lessons in running order.

**3,004 lessons have an ingested transcript** (September 2026), available in
full from the single-lesson endpoint. Transcripts are ingested by
`backfill-transcripts.js`, which runs automatically as part of the sheet → DB
sync, from the lesson's Drive SRT file with Wistia captions as the fallback. A
lesson whose caption file exists therefore gets its transcript on the next
sync, and `has_transcript` turns `true` at the same time.

The intended workflow is two-step:

1. **Find the lesson or course** with `/lessons/search`, `/lessons/by-tags` or
   the browse endpoints.
2. **Pull the detail record** for it — that's where descriptions, links and
   transcripts live.

For a content sync you can skip step 1 and walk `/courses` and `/lessons`
directly — see [Recipes](#recipes).

---

## Replacing the metadata spreadsheet

Everything that used to live only in the "Courses List" Google Sheet is now
served here, plus the Kajabi product id (added 2026-08-10, after the sheet
itself was backfilled with the same id in column K):

| Spreadsheet column (roughly) | API field | Endpoint |
|---|---|---|
| Course title | `title` | `/courses`, `/courses/:slug` |
| Slug | `slug` | same |
| Talent / teacher credit | `teacher` (free text) + `teachers[]` (resolved records) | same |
| Category | `category` | same |
| Description | `description` (long) / `short_description` | same |
| Product URL | `url` | same |
| Cover / thumbnail image | `imageurl`, `cover_image` | same |
| Promo video / poster | `promo_video`, `promo_poster` | same |
| Kajabi product ID (column K) | `product_id` | same |
| Resources folder | `resources` | same |
| Topic(s) | `topics[]`, `consolidated_topic` | same |

If a field you had in the sheet isn't in this table, it likely isn't tracked
in the courses table either — say so and we'll check.

---

## Key concepts

- **`lessonid`** — The Kajabi lesson id, and the key for `/lessons/:lessonid`.
  Returned as a **string** (it exceeds 32 bits), so compare it as one.
- **`slug`** — A course's key, e.g. `SG_21DYC`. Used for `/courses/:slug` and
  as `course_slug` on a lesson.
- **`product_id`** — The Kajabi **product** id (the join key described above).
  Returned as a JSON number. `null` on the small number of courses that
  predate the backfill or have no Kajabi product behind them.
- **`teacher`** — The credit line as typed in the source sheet ("Dr. Gabor
  Maté", "Eoin Finn & Insiya Rasiwala-Finn"). Free text, can name two people.
- **`teachers[]`** — The *canonical*, deduplicated teacher records for a
  course: `{id, name, headshot}`. A combined credit produces two entries.
  Prefer these ids over the `teacher` string when you need identity.
- **`tags`** — Controlled labels per lesson, drawn from its category, style,
  level and music flags (e.g. `["Practice", "Yoga", "Level 1", "No Music"]`).
  This is what `/lessons/by-tags` matches on and what the `tags` filter on
  search uses. Populated on every lesson; 421 carry `[]` because they are
  text-only posts with no facets.
- **`video_filename`** — The source asset filename, e.g.
  `Day06_part2_16x9_long_JB_BYH`. **This is the identity key for a class**:
  two rows with the same filename are the same video. Normalized before it is
  returned, so it is either a usable filename or `null` — placeholder junk
  like `-`, `N/A` and `TBD` never reaches you. Bare integers (`197`) are real
  identifiers for WL_STUDIO, not junk. Present on 4,104 of 4,808 lessons; the
  704 without are mostly the 422 text-only posts.
- **`summary`** — 2–3 factual sentences describing what the lesson covers,
  written to be neutral rather than promotional. Populated library-wide.
  `summary_source` (on the single-lesson endpoint) records how it was derived:
  `transcript` for the lessons whose transcript was ingested — those describe
  what was actually said — or `description` for the rest, paraphrased from the
  title and description and correspondingly weaker.
- **`resources`** — A Google Drive folder URL holding the PDFs and worksheets
  for that lesson or course, when one exists (841 lessons). It is a **link for
  a human to open**, not an API: there is no listing endpoint and the contents
  are not enumerable through this API. `null` means no folder is on file,
  which is the common case.
- **`description_markup_resource_title` / `description_markup_resource_link`**
  (added 2026-09-25) — The lesson's downloadable resource: a friendly label
  ("DOWNLOAD THE DAILY HANDOUT") and a Google Drive link to the file, lifted
  out of the lesson's description markup by the content team. **Show the
  title, not the file name** — file names in the folders are working names
  like `JB_BYH_L02_Day 1- Hormones 101….pdf`. Every link points into the
  course's own resources folder (the course's `resources` field): a file in
  it, or — for two JJ Virgin lessons whose handouts were always a folder —
  the folder itself. A lesson can have up to three resources: the second and
  third come as `…_title_2` / `…_link_2` and `…_title_3` / `…_link_3`. All six
  are strings or `null`, and a title is never set without its link. As of
  2026-09-25, 874 lessons have a first resource (a Drive link on 873, plus one
  external web page), 34 a second and 6 a third. Present on every lesson shape
  that carries `resources`; not on course objects. Titles are served exactly
  as the content team typed them, so a few carry HTML entities
  (`JJ&rsquo;S PROTEIN CHEAT SHEET`) — decode before display.

  ```json
  GET /api/library/lessons/5651098   (DLP_FC, abbreviated)
  {
    "resources": "https://drive.google.com/drive/folders/1vwqZCdkyUKsK2fCqVgxhjFgx5ovTdxhk",
    "description_markup_resource_title": "DOWNLOAD THE STUFF + VALUE WORKSHEET",
    "description_markup_resource_link": "https://drive.google.com/file/d/1GW_-KYWnp-FiewOmJQb-3q23LxGnEPcu/view",
    "description_markup_resource_title_2": "DOWNLOAD THE STOPPED DOING WORKSHEET",
    "description_markup_resource_link_2": "https://drive.google.com/file/d/1rIaiOq60sZuKRWsXT2mQ6_ddPzszVFU9/view",
    "description_markup_resource_title_3": "DOWNLOAD THE PATTERNS WORKSHEET",
    "description_markup_resource_link_3": "https://drive.google.com/file/d/1EGuQtHacYjmHpe0Ioo2bNkiJKsgMxo5f/view"
  }
  ```
- **`category` / `style` / `level`** — The lesson's own columns. `category`
  is one of `Growth`, `Health`, `Impact`, `Practice`, `Summit`. `style` is a
  **comma-composited** string (`Yoga,Vinyasa`, `Meditation,Breathwork`), so
  match it as a substring. `level` is `All Levels`, `Level 1`, `Level 2`,
  `Level 3`, `Level 1, Level 2` or `-`.
- **`runtime_min`** — Lesson length in whole minutes.
- **`has_video`** — `true` when the lesson has a source video link on file.
  Returned on every lesson row and on the lesson detail; 986 of the 4,738
  lessons have no video.
- **`rank`** — Relevance score on a search hit. Comparable within one result
  set, not across queries.

> **Hidden courses.** A course flagged *Hide in API Response* in the Courses
> List sheet (`hideinapi`) is omitted, together with **all of its lessons**,
> from every `/api/library/*` response and from every count and total in
> them — lesson lists, search, tags, teacher rows, pagination totals. The
> single-item lookups (`/lessons/:lessonid`, `/courses/:slug`) answer **404**
> for them, exactly as for a lesson or course that does not exist. There is no
> parameter that reveals them; the flag is edited in the sheet.

### Fields that are currently empty

Documented so you don't design around them. These are real columns the API
returns; the enrichment that fills them hasn't run yet.

| Field | Where | State |
|-------|-------|-------|
| `year` | lessons | `null` on all lessons — so the `year`, `year_from`, `year_to` filters match nothing |
| ~~`has_transcript`~~ | lesson lists | **Fixed.** It used to read `false` on every row because the backing flag was never set; it is now accurate, and `?has_transcript=true` returns the 3,004 lessons that have one. |
| `short_description` | lessons | empty — use `long_description` (4,309 lessons have one) |
| `price` | courses | `null` on all courses |

`summary` and `tags` used to be listed here and are now populated on the whole
library. `episodes` and `runtime_min` on courses are populated but **deprecated
and wrong** — see below.

### Deprecated course fields — don't quote these

| Field | Why | Use instead |
|-------|-----|-------------|
| `episodes` | Disagrees with the actual lesson count on 178 of 185 courses (2026-09-03), and is not consistently typed — some rows store the string `"5 weeks"`. COM_WLONG reports 3 for a course with 47 lessons. | `lesson_count_raw` / `lesson_count_distinct` / `lesson_count_canonical` — see the breaking note under Canonicalization for which is which |
| `runtime_min` (top-level) | Wrong on at least 6 courses, in both directions. | `runtime_min_raw` / `runtime_min_distinct` / `runtime_min_canonical` |

Both are still returned so nothing breaks. Don't quote either one.

---

## Canonicalization

The library holds more lesson rows than distinct classes (4,822 rows, 4,318
canonical, at the 2026-09-09 recompute). The duplication is correct platform
behaviour, not a bug — it faithfully records that a summit reused a video,
that a course ran both live and self-paced, or that a yoga class ships silent
and with music.

What was missing was any way to tell which rows are the same thing, so every
consumer re-derived it and each got a different answer. That is now computed
once, server-side, and returned on every lesson. For a plain content sync you
can generally ignore these fields and take every row; they matter when you're
trying to count "how many distinct classes" rather than "how many rows," or
to pick the one row to cite.

| Field | Type | Meaning |
|-------|------|---------|
| `canonical_lesson_id` | string | The lessonid to cite for this class. Equals the row's own id when it is canonical, so it **doubles as the group key** — rows sharing one are the same class. |
| `is_canonical` | bool | This is the row to link to and cite. |
| `counts_once_with` | string / null | Music-twin partner's lessonid. **Both rows are canonical** — either one completes the session — but the pair contributes **1** to any total. |
| `origin_course_slug` | string / null | The course the video originated in, parsed from the filename. `null` when it doesn't resolve (12 of 111 reused videos). |
| `edition` | enum | `evergreen`, `live`, or `unspecified`. |
| `duplicate_reason` | enum / null | `cross_course`, `edition`, `music_twin`, `summit_reuse`, or `near_duplicate`. The last one also appears on **canonical** rows: it means the row is a member of a near-duplicate candidate group awaiting a human decision in Commune's internal review queue — nothing has been merged yet. |
| `is_housekeeping` | bool | Welcome, Thank You (including "A Thank You Message from …"), trailers, course overviews. A Welcome-titled row whose filename names a numbered lesson (`Day01_…`) is a class, not housekeeping. 560 rows. |
| `is_text_post` | bool | A post with nothing to play: **no video file, no video link, no Wistia id, no transcript, no runtime.** Derived from that evidence on every recompute (2026-09-03), so it cannot drift. 505 rows: 414 with no course at all (those are also housekeeping) and 91 inside courses — reflection prompts, recipes, key-takeaway notes, worksheets. Orthogonal to `is_housekeeping`. **Filter on this flag**, not on a null `runtime_min`: today every null-runtime row is a text post or housekeeping, but that holds by construction of this flag, not by contract of the runtime column — a video ingested before its runtime is filled would slip through a runtime filter silently, whereas it trips the catalog's own invariant check and gets fixed. |
| `needs_review` | bool | Pending human adjudication: a course+title collision no rule settled, or membership of a near-duplicate candidate group. 430 rows in 174 groups (2026-09-03). |
| `near_duplicate_group_key` | string / null | Set on every member of an undismissed near-duplicate candidate group (decided or not): the members' lessonids, sorted and comma-joined — the key Commune's review tooling adjudicates on. `null` otherwise. Added 2026-09-03 so a `duplicate_reason: "near_duplicate"` row says *what* it is a near duplicate of. |
| `near_duplicate_with` | string[] | The other members of that group (derived from the key; `[]` when there is none). A near-duplicate flag is therefore readable from a plain lesson fetch; the decision itself is made by Commune staff. |

### Query parameters

Available on `/lessons`, `/lessons/search`, `/lessons/by-tags`, and the nested
lessons of `/courses/:slug`.

| Param | Default | Behavior |
|-------|---------|----------|
| `canonical_only` | `false` | When `true`, return only `is_canonical` rows. |
| `include_housekeeping` | `true` | When `false`, drop `is_housekeeping` rows. |

**Both defaults preserve the previous behaviour exactly**, so existing callers are
unaffected. `/lessons` returns every row by default and only the canonical
rows with `canonical_only=true`. `/lessons/search` ranks canonical rows above
their own duplicates at equal relevance.

### BREAKING 2026-09-03 — `lesson_count_canonical` now means what `is_canonical` means

Two different questions have always hidden behind the word *canonical*:

1. **How much of this course is the library-wide citation for its class?** —
   the row flag `is_canonical`. A summit that borrows everything scores near
   zero here, correctly.
2. **How many distinct classes does a student receive in this course?** — rows
   of one class count once, the two halves of a music twin count once,
   *wherever the citation lives*. COM_WLONG scores 47 here, correctly.

Until 2026-09-03 the course field `lesson_count_canonical` answered question 2
while the row field `is_canonical` answered question 1 — one word, two
meanings, and consumers summing `is_canonical` rows per course got a different
number from the course field (COM_MC read 73 against 26 canonical rows). That
is fixed by making the word mean one thing:

| Course field | Answers | Rule | COM_MC | COM_WLONG | BS_CYOA |
|---|---|---|---|---|---|
| `lesson_count_raw` | rows in the course | `COUNT(rows)` | 73 | 47 | 10 |
| `lesson_count_canonical` | **question 1** — unique contribution to the library pool | `COUNT(rows WHERE is_canonical)` — **changed 2026-09-03; was question 2** | **26** (was 73) | **1** (was 47) | 10 (was 5) |
| `lesson_count_distinct` | **question 2** — classes a student gets | one per identity group, music twins once — **new 2026-09-03, the old `lesson_count_canonical`** | 73 | 47 | 5 |
| `runtime_min_raw` / `runtime_min_canonical` / `runtime_min_distinct` | the same three, in minutes | same rules | | | |

**The consistency rule:** a course-level `*_canonical` field is always the
aggregate of that course's `is_canonical` rows, nothing else. Summing
`is_canonical` over `/courses/:slug` lessons equals `lesson_count_canonical`
for every course; the sync's invariant check fails if it ever does not.

So: `canonical_only=true` and `lesson_count_canonical` now agree — on a course
that borrows its content both legitimately show almost nothing (COM_WLONG,
JK_GS). **"How much content is in this course" is `lesson_count_distinct`**,
not a filtered row count. BS_CYOA shows why the two must never be conflated:
five music-twin pairs are ten canonical rows (either cut completes the
session) and five classes.

If you were reading `lesson_count_canonical` before 2026-09-03 and want the
number you had, read `lesson_count_distinct`.

---

## Sorting and paging

Every list endpoint has exactly **one** order, and none of them takes a `sort`
parameter:

| Endpoint | Order |
|----------|-------|
| `/lessons/search` | Relevance (`rank` descending) |
| `/lessons/by-tags` | Play completions, most-played first |
| `/lessons` | `course_slug`, then `course_order`, then `lessonid` |
| `/courses`, `/courses/by-topic` | Course title, A–Z |
| `/teachers` | Lesson count descending, then name |
| `/tags` | Count descending, then tag |

Paging is `limit` + `offset` on every list endpoint except `/tags`, which
takes `limit` only. **Limits are clamped, not rejected**: `?limit=500` on
`/lessons` returns 200 rows, and `?limit=200` on `/lessons/search` returns 100.
A missing or non-numeric `limit` means 50. `offset` is clamped to 0–100,000.
Maximums differ per endpoint and are noted below. If you need another order,
pull the result set and sort it yourself; that's exactly what the search tool
does.

---

## Errors

Every error is JSON of the form `{"error": "<message>"}`:

| Status | When |
|--------|------|
| `400` | A required parameter is missing or empty (`q` on search, `tags` on by-tags, `topics` on by-topic), or `:lessonid` isn't a number. The message names the parameter. |
| `404` | `Lesson not found`, `Course not found`, `Teacher not found` (numeric teacher id only): the row doesn't exist or belongs to a hidden course. |
| `500` | A database failure: `Search error` (search), `Tag query error` (by-tags), `Query error` (everything else). |

---

## Endpoints

### 1. Browse Courses

`GET /api/library/courses`

The course catalog, alphabetical. All ~185 fit in one call.

| Param | Type | Description |
|-------|------|-------------|
| `teacher` | string | Partial match on the course credit line |
| `category` | string | Exact match |
| `topic` | string | Partial match against the pipe-separated topic column |
| `limit` | integer | Default 50, max 200 |
| `offset` | integer | Default 0 |

**Example — the entire catalog in one request:**

```bash
curl -s "https://communeapi.com/api/library/courses?limit=200"
```

**Response:**

```json
{
  "total": 185,
  "offset": 0,
  "limit": 200,
  "results": [
    {
      "slug": "EF_10DGR",
      "title": "10-Day Gentle Reset Challenge",
      "teacher": "Eoin Finn & Insiya Rasiwala-Finn",
      "category": "Practice",
      "level": "All Levels",
      "topics": ["Yoga", "Yoga (Vinyasa)"],
      "consolidated_topic": "Yoga – All Levels",
      "consolidated_order": 11,
      "episodes": 10,
      "runtime_min": 387,
      "lesson_count_raw": 10,
      "lesson_count_canonical": 10,
      "lesson_count_distinct": 10,
      "runtime_min_raw": 387,
      "runtime_min_canonical": 387,
      "runtime_min_distinct": 387,
      "price": "N/A",
      "short_description": "The 10-Day Gentle Reset Challenge with Eoin Finn and Insiya Rasiwala-Finn offers yoga classes designed to detoxify and reset the body while building resilience.",
      "url": "https://www.onecommune.com/products/10-day-gentle-reset-challenge-with-eoin-finn-and-insiya-rasiwala-finn",
      "imageurl": "https://kajabi-storefronts-production.kajabi-cdn.com/…jpg",
      "description": "The 10-Day Gentle Reset Challenge with Eoin Finn and Insiya Rasiwala-Finn offers yoga classes designed to detoxify and reset the body while building resilience.",
      "promo_poster": null,
      "promo_video": null,
      "resources": "https://drive.google.com/drive/folders/…",
      "cover_image": "https://drive.google.com/file/d/…/view",
      "audio": null,
      "product_id": 2149310031,
      "teachers": [
        { "id": 54, "name": "Eoin Finn", "headshot": "https://drive.google.com/file/d/…/view" },
        { "id": 59, "name": "Insiya Rasiwala-Finn", "headshot": "https://drive.google.com/file/d/…/view" }
      ],
      "hideincourses": null,
      "hideinlibrary": null,
      "hideinrecommendations": null
    }
  ]
}
```

`topics` is the source `topic` column split on `|`. `consolidated_topic` /
`consolidated_order` are the curated grouping used for merchandising
("Yoga – Foundational").

> Five slugs appear on more than one row in the source table, so a browse page
> can show the same course twice. `/courses/:slug` returns one of them.

---

### 2. Courses by Topic

`GET /api/library/courses/by-topic`

Courses matching **any** of the given topics — an OR, unlike the tag
endpoint's AND.

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `topics` | string | yes | **Pipe**-separated (`Yoga\|Meditation`). Missing → `400` |
| `limit` | integer | no | Default 50, max 200 |
| `offset` | integer | no | Default 0 |

Matching is a substring test against the raw topic column, so `Yoga` also
matches `Yoga (Vinyasa)`.

```
GET /api/library/courses/by-topic?topics=Yoga%7CMeditation
```

```json
{
  "topics": ["Yoga", "Meditation"],
  "total": 75,
  "results": [ /* same course shape as Browse Courses */ ]
}
```

---

### 3. Get One Course

`GET /api/library/courses/:slug`

One course plus **all of its lessons in running order** — the single most
useful call for a per-course content sync, or for building a course page.

Everything from the Browse Courses shape, plus `lessons[]`:

| Lesson field | Description |
|--------------|-------------|
| `lessonid`, `title` | Identity |
| `day_subcat`, `course_order` | Where it sits in the course ("Day 4", position 35) |
| `runtime_min`, `style`, `level` | Practice metadata |
| `has_transcript` | `true` when the lesson has an ingested transcript |
| `has_video` | `true` when the lesson has a source video link |
| `video_filename` | The class identity key — see Key concepts |
| `summary`, `tags`, `resources` | As on any lesson row (see Browse Lessons) |
| `description_markup_resource_title`, `description_markup_resource_link`, `…_title_2`, `…_link_2`, `…_title_3`, `…_link_3` | The lesson's downloadable resources — see Key concepts |
| `canonical_lesson_id`, `is_canonical`, … | The canonicalization block, as on any lesson row |

The nested lessons take the `canonical_only` and `include_housekeeping`
parameters described under Canonicalization.

```
GET /api/library/courses/SG_21DYC
```

Returns `404 {"error": "Course not found"}` for an unknown or hidden slug.

---

### 4. Browse Lessons

`GET /api/library/lessons`

The whole lesson table with filters, in course running order. Use it for
systematic passes — "every lesson in this course", "everything this teacher
recorded".

| Param | Type | Description |
|-------|------|-------------|
| `teacher` | string | Partial match on the credit line |
| `category` | string | Exact match |
| `style` | string | Comma-separated list; matches a lesson whose style contains **any** of them |
| `level` | string | Exact match (`Level 1`, `All Levels`, …) |
| `course_slug` | string | Exact match — every lesson of one course |
| `has_transcript` | boolean | `true` returns the 3,004 lessons with an ingested transcript. Only `true` is honoured; any other value is ignored |
| `year` | integer | Matches nothing today |
| `canonical_only` | boolean | `true` returns only canonical rows. Default `false`. |
| `include_housekeeping` | boolean | `false` drops Welcome/Thank You/trailer rows. Default `true`. |
| `limit` | integer | Default 50, max 200 |
| `offset` | integer | Default 0 |

**Example — one course in running order:**

```
GET /api/library/lessons?course_slug=SG_21DYC&limit=200
```

**Example — every meditation or breathwork lesson:**

```
GET /api/library/lessons?style=Meditation,Breathwork&limit=200
```

**Response:**

```json
{
  "total": 4808,
  "offset": 0,
  "limit": 50,
  "results": [
    {
      "lessonid": "2658470",
      "title": "Notice",
      "course": "Yoga for All",
      "course_slug": "AM_ALLFORYOGA",
      "teacher": "Adriene Mishler",
      "category": "Practice",
      "style": "Yoga",
      "level": "Level 1",
      "runtime_min": 26,
      "day_subcat": "Day 1",
      "course_order": 1,
      "has_transcript": false,
      "has_video": true,
      "year": null,
      "video_filename": "Day01_16x9_long_AM_ALLFORYOGA",
      "summary": "A 26-minute yoga practice introducing…",
      "tags": ["Practice", "Yoga", "Level 1", "No Music"],
      "resources": null,
      "description_markup_resource_title": null,
      "description_markup_resource_link": null,
      "description_markup_resource_title_2": null,
      "description_markup_resource_link_2": null,
      "description_markup_resource_title_3": null,
      "description_markup_resource_link_3": null,

      "canonical_lesson_id": "2658470",
      "is_canonical": true,
      "counts_once_with": null,
      "origin_course_slug": "AM_ALLFORYOGA",
      "edition": "unspecified",
      "duplicate_reason": null,
      "is_housekeeping": false,
      "is_text_post": false,
      "needs_review": false,
      "near_duplicate_group_key": null,
      "near_duplicate_with": []
    }
  ]
}
```

This is the full lesson-row shape. The search and by-tag endpoints below
return the same `video_filename` and the same canonicalization block on every
row; their examples show the complete field set (values illustrative).

---

### 5. Get One Lesson

`GET /api/library/lessons/:lessonid`

Everything about a single lesson, including its **full transcript** and its
links. `:lessonid` must start with digits; it is read with `parseInt`, so
`abc` is a `400` and `123abc` is looked up as `123`.

Adds to the browse fields:

| Field | Description |
|-------|-------------|
| `has_transcript` | `true` when transcript text exists (3,004 lessons) |
| `has_video` | `true` when `video_link` is on file |
| `transcript_text` | The complete transcript as plain text. `null` when there is none |
| `srt_content` | Raw SRT, when the caption file itself was stored (currently always `null`) |
| `long_description` | The lesson's marketing/description copy |
| `short_description` | Currently empty on every lesson |
| `community_question` | The discussion prompt from the sheet, or `null`. **Placeholder text is served as `null`** (2026-09-03): a stored value that, trimmed and lower-cased, is one of `n/a`, `na`, `none`, `-`, `--`, `tbd` or empty comes back as `null`, so `if (community_question)` means what it says. The stored cell is left exactly as the content team wrote it; only the response is coerced. 1,117 of 4,821 rows hold such a placeholder, so true coverage is 3,188 rows (66 %), not the 4,305 non-null cells (89 %). |
| `summary_source` | How `summary` was derived: `transcript` or `description` — see Key concepts |
| `music` | Music credit for the track behind the practice |
| `url` | The lesson's page on onecommune.com |
| `course_url` | The parent course's page, joined via `course_slug` |
| `video_link` / `srt_link` / `thumbnail_link` | Source asset URLs |
| `srt_file_id` / `video_filename` | Drive id and filename of the source assets |

**Example:**

```
GET /api/library/lessons/2658470
```

**Response (abbreviated):**

```json
{
  "lessonid": "2658470",
  "title": "Notice",
  "course": "Yoga for All",
  "course_slug": "AM_ALLFORYOGA",
  "teacher": "Adriene Mishler",
  "category": "Practice",
  "style": "Yoga",
  "level": "Level 1",
  "runtime_min": 26,
  "day_subcat": "Day 1",
  "course_order": 1,
  "year": null,
  "music": "No Music",
  "has_transcript": true,
  "has_video": true,
  "video_filename": "Day01_16x9_long_AM_ALLFORYOGA",
  "summary": "A 26-minute yoga practice introducing…",
  "summary_source": "transcript",
  "tags": ["Practice", "Yoga", "Level 1", "No Music"],
  "community_question": null,
  "long_description": "Your Day 1 practice offers an invitation to slow down and take stock.\n\nSet the tone and lay the foundation for…",
  "url": "https://www.onecommune.com/products/yoga-for-all-with-adriene-mishler/categories/1766797/posts/2658470",
  "course_url": "https://www.onecommune.com/products/…",
  "video_link": "https://drive.google.com/file/d/…/view",
  "srt_link": "https://drive.google.com/file/d/…/view",
  "thumbnail_link": "https://…jpg",
  "resources": null,
  "description_markup_resource_title": null,
  "description_markup_resource_link": null,
  "description_markup_resource_title_2": null,
  "description_markup_resource_link_2": null,
  "description_markup_resource_title_3": null,
  "description_markup_resource_link_3": null,
  "transcript_text": "- We have arrived at the end, my friends. But, of course, you know an ending is just another beginning…",
  "srt_content": null,
  "canonical_lesson_id": "2658470",
  "is_canonical": true
}
```

Returns `400 {"error": "Invalid lessonid"}` for a non-numeric id and `404
{"error": "Lesson not found"}` for an unknown or hidden one.

---

### 6. Search Lessons

`GET /api/library/lessons/search`

Full-text search over the lesson index. The index is built from three
weighted tiers: the **lesson title** (highest), then the **summary, community
question and tags**, then the **long description** (lowest), so a title hit
outranks a description-only hit. Transcripts are **not** indexed; pull them
from the single-lesson endpoint.

Stemming is English (`meditate` finds *meditation*), and terms are ANDed
(`plainto_tsquery`); quoted-phrase and `OR` syntax is **not** supported here.

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `q` | string | yes | Search terms. Missing or blank → `400` |
| `tags` | string | no | Comma-separated tags; a lesson must carry **all** of them |
| `teacher` | string | no | Partial match on the credit line |
| `category` | string | no | Exact match: `Growth`, `Health`, `Impact`, `Practice`, `Summit` |
| `style` | string | no | Partial match — `Yoga` also matches `Yoga,Vinyasa` |
| `has_transcript` | boolean | no | `true` restricts to lessons with an ingested transcript. Only `true` is honoured; any other value is ignored |
| `year_from` / `year_to` | integer | no | Match nothing today — `year` is unpopulated |
| `canonical_only` / `include_housekeeping` | boolean | no | See Canonicalization |
| `limit` | integer | no | Default 50, max 100 |
| `offset` | integer | no | Default 0 |

**Example — breathwork lessons, most relevant first:**

```
GET /api/library/lessons/search?q=breathwork
```

**Example — restorative yoga taught by Schuyler Grant:**

```
GET /api/library/lessons/search?q=restorative&teacher=Schuyler&category=Practice
```

**Response (values illustrative; the field set is exactly what the endpoint
returns — the Browse Lessons row plus `rank`):**

```json
{
  "query": "breathwork",
  "total": 99,
  "results": [
    {
      "lessonid": "12150645",
      "title": "Actve Breathwork: Heart Chakra",
      "course": "Abundant Joy",
      "course_slug": "SS_JOY",
      "teacher": "Scott Schwenk",
      "category": "Practice",
      "style": "Breathwork",
      "level": "All Levels",
      "runtime_min": 36,
      "day_subcat": "Day 4",
      "course_order": 35,
      "has_transcript": false,
      "has_video": true,
      "year": null,
      "video_filename": "…",
      "summary": "…",
      "tags": ["Practice", "Breathwork", "All Levels", "Music"],
      "resources": null,
      "description_markup_resource_title": null,
      "description_markup_resource_link": null,
      "description_markup_resource_title_2": null,
      "description_markup_resource_link_2": null,
      "description_markup_resource_title_3": null,
      "description_markup_resource_link_3": null,

      "canonical_lesson_id": "12150645",
      "is_canonical": true,
      "counts_once_with": null,
      "origin_course_slug": "SS_JOY",
      "edition": "unspecified",
      "duplicate_reason": null,
      "is_housekeeping": false,
      "is_text_post": false,
      "needs_review": false,
      "near_duplicate_group_key": null,
      "near_duplicate_with": [],

      "rank": 0.076
    }
  ]
}
```

`video_filename` is a string or `null`, as on every lesson row.

> This endpoint echoes `query` and `total` but **not** `limit`/`offset`,
> unlike the browse endpoints. Page with the values you sent.

---

### 7. Lessons by Tag

`GET /api/library/lessons/by-tags`

AND-match on the tag vocabulary, ranked by how many times the lesson has been
completed. This is the "what's popular in X" endpoint.

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `tags` | string | yes | Comma-separated. A lesson must carry **every** tag listed. Missing → `400 "tags" parameter is required (comma-separated)`; only commas/blanks → `400 "tags" must include at least one tag` |
| `canonical_only` / `include_housekeeping` | boolean | no | See Canonicalization |
| `limit` | integer | no | Default 50, max 100 |
| `offset` | integer | no | Default 0 |

**Example — the most-played all-levels breathwork:**

```
GET /api/library/lessons/by-tags?tags=Breathwork,All%20Levels
```

**Response (values illustrative; the field set is exactly what the endpoint
returns):**

```json
{
  "tags": ["Breathwork", "All Levels"],
  "total": 118,
  "results": [
    {
      "lessonid": "4830366",
      "title": "Body: The First Reality",
      "course": "Ecstatic Breathwork",
      "course_slug": "SS_EB",
      "teacher": "Scott Schwenk",
      "runtime_min": 33,
      "has_transcript": false,
      "has_video": true,
      "video_filename": "…",
      "summary": "…",
      "tags": ["Practice", "Breathwork", "All Levels", "Music"],
      "resources": null,
      "description_markup_resource_title": null,
      "description_markup_resource_link": null,
      "description_markup_resource_title_2": null,
      "description_markup_resource_link_2": null,
      "description_markup_resource_title_3": null,
      "description_markup_resource_link_3": null,
      "total_plays": 14797,

      "canonical_lesson_id": "4830366",
      "is_canonical": true,
      "counts_once_with": null,
      "origin_course_slug": "SS_EB",
      "edition": "unspecified",
      "duplicate_reason": null,
      "is_housekeeping": false,
      "is_text_post": false,
      "needs_review": false,
      "near_duplicate_group_key": null,
      "near_duplicate_with": []
    }
  ]
}
```

`total_plays` is unique to this endpoint (and to `top_lessons` on a teacher).
Rows here carry a **smaller field set** than the other lesson lists — no
`category`, `style`, `level`, `day_subcat`, `course_order` or `year` — but the
full canonicalization block is present on every row.

Only the `tags` filter (plus the two canonicalization switches) applies:
teacher/category/style are **not** accepted here.

---

### 8. List Teachers

`GET /api/library/teachers`

Everyone credited on a lesson or course, most prolific first, merged across
spelling variants — "Dr. Gabor Maté" and "Gabor Maté, MD" are one row with
pooled counts.

| Param | Type | Description |
|-------|------|-------------|
| `q` | string | Partial name match |
| `limit` | integer | Default 50, max 500 |
| `offset` | integer | Default 0 |

```json
{
  "total": 202,
  "results": [
    {
      "name": "Schuyler Grant",
      "lesson_count": 381,
      "course_count": 15,
      "id": 114,
      "bio": "Meet Schuyler Grant, your teacher and guide …",
      "bio_source": "kajabi",
      "headshot": "https://drive.google.com/file/d/…/view",
      "kajabi_title": "Instructor",
      "linked_course_count": 16
    }
  ]
}
```

- `total` is the size of the filtered set, not of the page.
- `id` is `null` for a credit line with no canonical teacher record behind it
  (combined credits, and the row for lessons with no credit at all).
- `course_count` counts courses whose credit line is this teacher (for a
  canonical teacher who appears in no credit line, it is the linked count);
  `linked_course_count` counts courses linked to them through the canonical
  join, and is the better number when a teacher co-taught. It is `null` when
  `id` is `null`.
- `total` reads `0` when `offset` is past the end of the set.

---

### 9. Get One Teacher

`GET /api/library/teachers/:id`

`:id` accepts **either** a numeric teacher id **or** a URL-encoded name /
partial name.

```
GET /api/library/teachers/114
GET /api/library/teachers/Schuyler%20Grant
```

| Field | Description |
|-------|-------------|
| `name` | The canonical name on an id lookup. On a name lookup it echoes the name you sent, whatever it matched |
| `id`, `bio`, `bio_source`, `headshot`, `kajabi_title` | The canonical record, all `null` when the name matched no teacher row. A name lookup resolves to the teacher whose `name_key` equals the lower-cased name, else the first (alphabetical) whose name contains it |
| `course_slugs` | Slugs linked to this teacher through the canonical join (`[]` when no teacher row matched) |
| `courses[]` | Their courses. A subset of the Browse Courses shape: `slug`, `title`, `category`, `topics`, `consolidated_topic`, `consolidated_order`, `episodes`, `runtime_min` (both deprecated), the six `lesson_count_*` / `runtime_min_*` fields, `promo_video`, `resources`, `cover_image`, `audio`, `product_id`, `teachers[]`. No `teacher`, `level`, `price`, `short_description`, `description`, `url`, `imageurl`, `promo_poster` or `hidein*` flags |
| `top_lessons[]` | Their ten most-played lessons: `lessonid`, `title`, `course`, `course_slug`, `runtime_min`, `has_video`, `video_filename`, `summary`, `tags`, `resources`, the six `description_markup_resource_*` fields, `total_plays`, plus the canonicalization block. No `teacher` or `has_transcript` |

Behaviour differs by lookup form, deliberately:

- **By id** — courses come from the canonical join, so a co-taught course
  appears for both teachers. An unknown id is a `404`.
- **By name** — courses and lessons come from a substring match on the credit
  line, which is the older behaviour kept for existing callers. An unmatched
  name is **`200` with empty arrays**, not a 404.

Prefer the id form. Get ids from `/teachers` or from any course's `teachers[]`.

---

### 10. List Tags

`GET /api/library/tags`

The whole tag vocabulary with lesson counts — 32 tags in total. Use it to
populate a filter, or to spell a `tags` value correctly.

| Param | Type | Description |
|-------|------|-------------|
| `q` | string | Partial tag match |
| `min_count` | integer | Only tags used at least this many times (default 1, max 10,000) |
| `limit` | integer | Default 50, max 1000. No `offset` |

```json
{
  "total": 32,
  "results": [
    { "tag": "Lecture",  "count": 2344 },
    { "tag": "Practice", "count": 1843 },
    { "tag": "Health",   "count": 1279 }
  ]
}
```

> `total` here is the number of rows **returned**, not the size of the
> vocabulary — `?limit=5` reports `"total": 5`. Ask for `?limit=1000` to get
> the real count. (The other list endpoints report a true total.)

---

### 11. Catalog Meta

`GET /api/library/meta`

"What is the API serving, as of when." The one-row stamp the catalog sync
writes on every recompute of the canonicalization columns — the nightly /
on-demand run, and every review decision Commune staff record. No caching
header: the answer is meant to be fresh.

```json
{
  "catalog_meta": {
    "last_recompute_at": "2026-09-09T16:27:59.556Z",
    "recompute_source": "refresh-catalog.js recompute()",
    "code_version": "a2167af",
    "counts": { "total": 4822, "canonical": 4318, "duplicates": 504 },
    "updated_at": "2026-09-09T16:27:59.556Z"
  },
  "served_at": "2026-09-14T23:27:09.957Z"
}
```

`catalog_meta` is `null` if the catalog has never been recomputed.
`code_version` is the short git SHA of the checkout when there is one, else
`package <version>+<hash>` (the package version plus a short hash of the
recompute script). `counts` are the figures of that recompute, over the raw
catalog (hidden courses included) — so `canonical` here can exceed the public
`?canonical_only=true` total; the recompute may also report additional review
counters alongside the three shown.

Use this to decide whether it's worth re-pulling — if `last_recompute_at`
hasn't moved since your last sync, the catalog hasn't changed.

---

### Internal endpoints (Commune staff)

Session-gated internal endpoints also exist for the needs-review queue and for
lesson inspection. They need a Commune tools sign-in with Library Search
access (an API key does not open them). They are not part of the public API
and are documented separately for Commune staff.

---

## Recipes

**"Sync the full course catalog, with the billing join key."**

```
GET /api/library/courses?limit=200
→ every course, including product_id — join to Origins/Kajabi product data on that field
```

**"Sync one course fully, including its lessons" / "Build a course page."**

```
GET /api/library/courses/<slug>
→ metadata, teachers[] with headshots, every lesson in running order
```

**"Pull every lesson."**

```
GET /api/library/lessons?limit=200&offset=<n>
→ walk with offset until a page comes back short of `limit`
```

**"Find a lesson about X and quote it."**

```
GET /api/library/lessons/search?q=X
→ take the lessonid of a hit
GET /api/library/lessons/<lessonid>
→ transcript_text has the full spoken content
```

**"What's most popular in a style?"**

```
GET /api/library/lessons/by-tags?tags=Yoga,Restorative
→ ordered by completions, so the top rows are the proven ones
```

**"Everything one teacher has taught."**

```
GET /api/library/teachers?q=<partial name>   → confirm the id
GET /api/library/teachers/<id>               → courses + their top lessons
GET /api/library/lessons?teacher=<name>&limit=200  → every lesson, course order
```

**"Is this row the one to cite, and as of when?"**

```
GET /api/library/lessons/<lessonid>
→ is_canonical says whether to cite it; canonical_lesson_id says which row to cite instead
GET /api/library/meta
→ last_recompute_at is when that answer was last computed
```

---

## Notes on the data

- **The source is a spreadsheet-backed table.** Credit lines, styles and
  levels are free text and carry the inconsistencies you'd expect (`-` for
  "none", `Yoga|Hatha|Vinyasa` alongside `Yoga,Hatha`). Match with substrings,
  not equality, wherever the docs say "partial".
- **`lessonid` is a string**; `product_id` is a **number**. `lessonid` comes
  from Kajabi and overflows a 32-bit int; JSON keeps it as a string so it
  survives the trip.
- **Transcripts are raw.** No speaker labels and no timecodes — a single block
  of spoken text per lesson.
- **Counts in this document are dated snapshots** and move as content is
  added. Read live figures from each endpoint's `total` and from
  `GET /api/library/meta` rather than reconciling against the numbers here.
- **Some courses are deliberately hidden.** The Courses List sheet's "Hide in
  API Response" column (`hideinapi`) removes a course and all of its lessons
  from every endpoint and count documented here; single-item lookups return
  404 for them. Only Commune's internal review tooling sees the raw catalog.
- **A filename is trusted only while it is consistent with itself**
  (2026-09-03). Two rows of one course that share a `video_filename` but not a
  title cannot both be right, so the recompute no longer merges them — each
  title is its own class until the sheet is corrected. 14 such filenames exist
  today (SG_LIVE_GG carried eleven different lessons under one placeholder
  filename; SG_SOL's "Week 5 Live Q&A Call" carried the "Introduction to
  Beautiful Hormones" file). They are reported to the content team by the
  sync.
- **Invariants the sync enforces** (`verify-full-sync.js`, fails the run on
  any hit): every null-`runtime_min` row is a text post or housekeeping; every
  non-canonical row points at an existing canonical row;
  `lesson_count_canonical` equals the course's `is_canonical` rows; no
  filename merges two titles inside one course. If you see a row that breaks
  one of these, it is a bug report to us, not a filter to write around.
- **Nothing here is rate-limited, and no key is required** (keys are only
  logged; see *Auth*), but it's a single small server shared with the live
  site — batch politely (sequential requests
  rather than large parallel fan-outs) and cache what you pull. `GET
  /api/library/meta` tells you when it's worth re-pulling at all.
