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
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:
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 anETag. SendIf-None-Matchto get304 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.
{
"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": "…"
}
]
}
freeLessonsis a number (that many lessons from the start are free) or"all".coveris the course's cover image (nullwithout one);coverImageUrlis the same URL.alt,widthandheightcan benull.partnersare the schools and companies the course is taught with, in order: show them under "In partnership with". A partner'slogoUrlis an image on the web, best a single-colour logo.joinsays 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:
{
"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.
{
"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\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] Titleblockquotes (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 idlesson-<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}
{
"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
{
"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
{
"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:
POST /your/webhook/route
Content-Type: application/json
Chalkenso-Event: content.published
Chalkenso-Signature: t=1791500000,v1=5f2b…
{
"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.
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.