Join the waitlist

Chalkenso opens soon. Leave your email and we'll tell you when; pilot spots go to the waitlist first.

One email when we open. We use your address for nothing else; to remove it, write to info@chalkenso.com. Privacy

Opening soon. Pilot spots go to the waitlist first. Join the waitlist →

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

{
  "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:

{
  "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![](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}

{
  "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.