# Chalkenso website API (v1)

Base URL: `https://chalkenso.com/api/v1` · OpenAPI 3.1: `https://chalkenso.com/api/v1/openapi.json` · Try it in the
[API explorer](https://chalkenso.com/docs/reference)

The API lets an organisation's own website show its published courses and lessons, serve learner
links, send learner feedback back to Chalkenso and keep learners' private notes. It is read-mostly
and server-to-server.

## Authentication

Every request (except images) needs a site key:

```http
Authorization: Bearer site_…
```

Create keys in Chalkenso under *Website*. A site key has two permissions: `content:read` (courses,
lessons, learner links) and `feedback:write` (learner feedback and notes). It can't change courses, and
Obsidian sync tokens can't be used here. Keep keys on the server.

## Responses

- Success: `{ "data": … }`, with an `ETag`. Send `If-None-Match` to get `304 Not Modified`.
- Error: `{ "error": { "code": "…", "message": "…" } }` with the HTTP status.

| Status | Codes |
|---|---|
| 400 | `invalid_json`, `lesson_required`, `feedback_rejected`, `note_rejected` (empty or too long) |
| 401 | `missing_key`, `invalid_key` |
| 403 | `forbidden` (key lacks the permission), `invalid_key` (key revoked), `learner_link_required`, `feedback_rejected`, `notes_unavailable` (not a personal learner link) |
| 404 | `course_not_found`, `course_not_ready`, `lesson_not_found`, `link_not_found`, `feedback_not_found`, `note_not_found`, `not_found` (images) |
| 409 | `link_unclaimed` |
| 410 | `link_expired`, `link_revoked`, `link_used` |

## Loading lessons

Outlines carry titles only. Load a lesson's text when a reader opens it, one request per lesson, and
cache it: don't fetch every lesson to build a page. The text is always Markdown that renders
anywhere; `?include=html` adds it rendered as HTML.

## Courses

### `GET /courses`

Every course with a published version, pinned first. Show the ones with `listed: true` in a public
catalogue.

```json
{
  "data": [
    {
      "id": "c…", "slug": "web-development-2", "title": "Web Development II",
      "summary": "…", "outcome": "…", "tags": ["Python", "Docker"],
      "githubUrl": "https://github.com/…",
      "listed": true, "pinned": false, "enrolUrl": "https://…",
      "coverImageUrl": "https://chalkenso.com/api/v1/assets/…",
      "cover": { "url": "https://chalkenso.com/api/v1/assets/…", "alt": "…", "width": 1200, "height": 630 },
      "partners": [
        { "id": "c…", "name": "Northwind Labs", "websiteUrl": "https://…", "logoUrl": "https://…", "description": "…" }
      ],
      "join": { "type": "link", "url": "https://…", "partner": null },
      "freeLessons": 2, "lessonCount": 16,
      "version": { "id": "c…", "label": "v2", "publishedAt": "2026-10-01T09:00:00.000Z" },
      "updatedAt": "…"
    }
  ]
}
```

- `freeLessons` is a number (that many lessons from the start are free) or `"all"`.
- `cover` is the course's cover image (`null` without one); `coverImageUrl` is the same URL. `alt`,
  `width` and `height` can be `null`.
- `partners` are the schools and companies the course is taught with, in order: show them under "In
  partnership with". A partner's `logoUrl` is an image on the web, best a single-colour logo.
- `join` says where the course's "Join" button leads:

| `join.type` | `join.url` | When |
|---|---|---|
| `partner` | The partner's website (`join.partner` names it) | The course joins through a partner that has a website |
| `link` | The course's own link (also in `enrolUrl`) | The course has its own sign-up link |
| `default` | `null` | Anything else: your site decides, for example a contact page |

### `GET /courses/{courseSlug}`

The course plus `parts` (in order, with their descriptions; only parts with published lessons) and
the lesson outline, titles only. Render this as the course page, and load each lesson when a reader
opens it:

```json
{
  "data": {
    "slug": "web-development-2", "title": "Web Development II", "…": "…",
    "parts": [{ "id": "c…", "title": "Part I – Server-rendered", "description": "…" }],
    "lessons": [
      {
        "id": "c…", "slug": "course-intro-docker-setup", "number": 1,
        "title": "Course intro, Docker setup and your first page",
        "shortDescription": "…", "part": { "id": "c…", "title": "Part I – Server-rendered" },
        "publishedAt": "…", "free": true
      }
    ]
  }
}
```

### `GET /courses/{courseSlug}/lessons/{lesson}`

One free lesson, by slug or id, as last published. Call it when a reader opens the lesson. Other
lessons answer `403 learner_link_required`.

```json
{
  "data": {
    "id": "c…", "slug": "course-intro-docker-setup", "number": 1, "title": "…",
    "shortDescription": "…", "part": { "id": "c…", "title": "…" }, "publishedAt": "…",
    "markdown": "## Run Flask and Postgres with one command\n\n![](https://chalkenso.com/api/v1/assets/…)\n\n…",
    "previous": null,
    "next": { "slug": "rest-api-crud", "title": "A REST API with full CRUD" },
    "free": true,
    "course": { "slug": "web-development-2", "title": "Web Development II" }
  }
}
```

About `markdown`:

- CommonMark with GFM tables. Images use stable URLs (`/api/v1/assets/{id}`) that redirect to the
  file: cache the lesson freely.
- Callouts are `> [!note] Title` blockquotes (GitHub's alert syntax). Code blocks are fenced with
  their language.
- Links to other lessons are `[Title](#lesson-<slug>)`: on a one-page course, give each lesson the
  id `lesson-<slug>`; otherwise point them at your own lesson URLs.

`?include=html` adds `html`, the same lesson rendered: code blocks are `div.lesson-code-block` >
`div.lesson-code-header` (language in `span.lesson-code-language`) and `div.lesson-code-scroll` >
`pre` > `code.language-…`, already highlighted (`hljs-…` classes); callouts are `div.callout` with
`.callout-title` and `.callout-content`; links to other lessons are `<a href="#lesson-<slug>"
data-lesson-slug="…">`.

## Learner links

Learners get a personal link token from Chalkenso (one per learner, per group). Serve it on your site
and read their course with it.

### `GET /access/{token}`

```json
{
  "data": {
    "learner": { "name": "Nina Kos" },
    "canSendFeedback": true,
    "canKeepNotes": true,
    "preview": false,
    "expiresAt": "…",
    "group": {
      "id": "c…", "name": "Evening group · Autumn 2026", "startsAt": "…", "endsAt": "…",
      "slackHandle": "#web-dev-autumn", "description": "…", "partners": []
    },
    "course": {
      "id": "c…", "slug": "web-development-2", "title": "…", "summary": "…",
      "githubUrl": "https://github.com/…", "partners": [{ "id": "c…", "name": "Northwind Labs", "…": "…" }]
    },
    "version": { "id": "c…", "label": "Evening group · Autumn 2026" },
    "parts": [{ "id": "c…", "title": "Part I – Server-rendered", "description": "…" }],
    "lessons": [{ "id": "c…", "slug": "…", "number": 1, "title": "…", "shortDescription": "…", "part": null, "publishedAt": "…" }]
  }
}
```

The lessons are the learner's group copy of the course, titles only: a group can differ from the
main course. Load each one when the learner opens it. `parts` are that copy's parts in order (only
those with lessons here).
Partners on a learner page: show `group.partners` when the group has any, otherwise
`course.partners`. `group` is `null` on team preview links.
`preview: true` marks a team preview link, which reads drafts and can't send feedback or keep notes.
Show the feedback box only when `canSendFeedback` is true, and notes only when `canKeepNotes` is.

### `GET /access/{token}/lessons/{lesson}`

One lesson, by slug or id, from the learner's group copy, in the same shape as the public lesson
endpoint (without `free` and `course`). Call it when the learner opens the lesson.

## Learner signals

What learners answer at the end of a lesson and in surveys. Most sites load the learner script
(`/embed/learner.v1.js`, see the integration guide), which does this and measures anonymous reading
statistics. Sites that draw their own questions use these endpoints from their server; they need
`feedback:write` and a personal learner link. `GET /access/{token}` returns `signals` for such links:
the switches the course or group has on, per lesson the revision learners read, this learner's answer
and the approved check question, and whether the surveys are open.

### `POST /access/{token}/lessons/{lesson}/pulse`

`{ "answer": "YES" | "NOT_YET" | "NOT_READ" | "DISMISSED" }` for the lesson question, or
`{ "optionIndex": 1 }` / `{ "notSure": true }` for the check question (the response says whether it
was right, which option is, and the section that teaches it). Then, optionally, `{ "sectionIndex": 2,
"sectionTitle": "…", "note": "…" }`: "Which part?" after either answer (`-1` is "Most of it") and a
one-line note. One answer per learner and lesson revision; another request changes it. `409
feature_off` when the course or group turned the feature off.

### `POST /access/{token}/survey`

`{ "kind": "END" | "MID", "score": 1-5 }` first, then any of `improveLessonId` or `improveNone`,
`answer`, `quoteConsent` (scores 4–5), `named`, `extraAnswer`, and for "3 quick questions"
`checkAnswers: [{ lessonId, optionIndex | null, notSure }]`. `{ "declined": true }` is "No thanks".

## Feedback

Requires `feedback:write`. Only personal learner links can send feedback.

### `POST /access/{token}/feedback`

```json
{
  "lesson": "course-intro-docker-setup",
  "selectedText": "Your laptop stays clean, and everyone in the room runs the same versions.",
  "context": "Docker runs each part of your app in its own small, isolated computer…",
  "body": "Which Docker version do I need?"
}
```

`201` with the saved feedback: `{ "data": { "id", "selectedText", "context", "body", "status", "adminResponse", "adminRespondedAt", "createdAt" } }`.
It shows up in the lesson's margin in Chalkenso, attributed to the learner.

### `GET /access/{token}/feedback?lesson={lesson}`

The learner's own feedback on that lesson, newest first, with replies (`adminResponse`).

### `DELETE /access/{token}/feedback/{id}`

Deletes the learner's own feedback. `204` on success.

## Notes

A learner's private notes on passages. They are the same notes the learner sees on Chalkenso's own
learner pages, and only the learner sees them: the team never does. Requires `feedback:write`. Only
personal learner links keep notes.

### `POST /access/{token}/notes`

```json
{
  "lesson": "course-intro-docker-setup",
  "selectedText": "docker compose ps",
  "context": "Run `docker compose ps`: both `web` and `db` should be running.",
  "body": "Run this before every class."
}
```

`201` with the saved note: `{ "data": { "id", "selectedText", "context", "body", "createdAt", "updatedAt" } }`.
`selectedText` anchors the note to the passage; `context` (the paragraph) helps find it again when
the passage appears more than once.

### `GET /access/{token}/notes?lesson={lesson}`

The learner's notes on that lesson, newest first. A link that can't keep notes gets an empty list.

### `PATCH /access/{token}/notes/{id}`

`{ "body": "…" }` changes what the note says and returns it. The passage stays as it was, so the
note keeps its place.

### `DELETE /access/{token}/notes/{id}`

Deletes the learner's note. `204` on success.

## Images

### `GET /assets/{id}`

No key needed: browsers load these. Redirects (`302`) to a short-lived file URL. Only images used in
a published lesson or as a course cover are served.

## Webhooks

Register an endpoint in Chalkenso under *Website* → *Publish notifications*. When lessons are
published (in the workspace, by publishing a version, or from Obsidian), Chalkenso sends:

```http
POST /your/webhook/route
Content-Type: application/json
Chalkenso-Event: content.published
Chalkenso-Signature: t=1791500000,v1=5f2b…
```

```json
{
  "id": "evt_…",
  "event": "content.published",
  "createdAt": "…",
  "course": { "id": "c…", "slug": "web-development-2", "title": "Web Development II" },
  "version": { "id": "c…", "label": "v2", "group": null },
  "lessons": [{ "id": "c…", "slug": "course-intro-docker-setup" }]
}
```

`version.group` is set when a group's copy was published; refresh learner pages only in that case.
The test button sends `event: "test"`.

Other events, signed the same way:

| `event` | Sent when | Payload besides `id`, `event`, `createdAt` |
|---|---|---|
| `content.unpublished` | Published lessons left a version: deleted, or the version went back to draft | `course`, `version`, `lessons` (as above; the lessons may be gone, so don't fetch them) |
| `course.updated` | The course's settings, cover, partners or Join button changed, or a partner it shows was edited or deleted | `course: { id, slug, title }` |

Treat any event you don't know as "refresh everything", and keep a time-based refresh as a fallback:
a notification can get lost.

Verify the signature before acting: HMAC-SHA256 of `"<t>.<raw body>"` with your webhook secret, hex
encoded, compared in constant time; reject anything older than five minutes.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyChalkensoSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length / 2 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
}
```

Answer `2xx` within five seconds; do the refresh after answering if it takes longer.
