The JWT's exp claim says 30 days, and I took that as the session
lifetime. It is only an outer ceiling. The server also keeps a per-token
whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is
JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate
re-sets it on every authenticated request. Two hours idle and the token
is rejected with 29 days still on exp.
Proven, not inferred: the token from yesterday returned 401 at 13.8h old.
The live instance publishes the values unauthenticated at
GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200,
JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the
one-hour UI prompt that prompted this investigation.
refresh-session turns out not to be special: it extends through the same
guard as any other route, and uniquely only in returning the remaining
TTL. So the keepalive uses GET /api/v3/me instead, and the server stays
GET-only; the one POST in the repo is in scripts/probe.mjs, where it
reports the idle budget.
JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but
is vestigial: privateDevice has no references in the current NestJS
source, and generateJwtAndAddToWhitelist never overrides the TTL.
Also fixes a real breakage this surfaced: TypeScript parameter
properties are rejected by Node's type stripping, so `npm run dev` and
`npm test` both failed on any file reaching them. Rewritten as explicit
fields, and noted in CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.1 KiB
The Schulcloud API, as verified against this instance
Everything here was confirmed against https://schulcloud-thueringen.de with a
real student account on 2026-09-11, not inferred from source. Where upstream
source and live behaviour disagreed, live behaviour won.
Two services, one origin
| Service | Source repo | Base path | Self-documenting at |
|---|---|---|---|
| Main server (NestJS) | schulcloud-server |
/api/v3/ |
/api/v3/docs, /api/v3/docs-json |
| Files storage | file-storage |
/api/v3/file/ |
/api/v3/file/docs, /api/v3/file/docs-json |
Both accept the same bearer token. The files service was split out of
schulcloud-server into its own repository, which is why no file paths
appear in the main docs-json — a detail that will send you in circles if you
only read the main spec. Fetch both:
curl -s "$TSC_URL/api/v3/docs-json" -o docs-v3.json # 212 paths
curl -s "$TSC_URL/api/v3/file/docs-json" -o docs-file.json # 26 paths
These are the authoritative reference for this instance's deployed version.
Prefer them over the GitHub sources, which track main and may be ahead.
Which repositories matter
The hpi-schul-cloud org has ~100 repos, most archived or superseded. The live
ones relevant here:
schulcloud-server— the API. Readapps/server/src/modules/<module>/api/for controllers and DTOs.file-storage— the files service, extracted from the above.src/modules/files-storage/api/controller/files-storage.controller.tsis the whole surface.nuxt-client— the current web front end. Useful for seeing which API calls the real UI makes in which order.schulcloud-client— the legacy Handlebars front end. Still receives commits, but it is not where new features land.
Superseded/archived and worth ignoring: authorization-service,
schulcloud-editor, nexboard-api-js, end-to-end-tests, docker-compose,
H5P-Nodejs-library, shd-client.
Note the naming: /api/v1 is the old Feathers surface. On this instance
/api/v1/docs 404s, and the v3 NestJS API covers everything this server needs.
Content model
Course ─┬─ column board ─── column ─── card ─── element ─┬─ richText
│ ├─ file ──── fileRecord(s)
│ ├─ link
│ └─ …
├─ lesson (Thema) ─── contents[] + materials[]
└─ task (Aufgabe) ─── description + fileRecord(s)
On the account this was built against: 26 courses holding 30 column boards, 18 lessons, 42 tasks and 175 files. Column boards hold the great majority of current material; lessons are the older format.
Endpoints this server uses
| Purpose | Call |
|---|---|
| Identity, school id, permissions | GET /api/v3/me |
| Courses | GET /api/v3/courses?skip&limit |
| One course's contents | GET /api/v3/course-rooms/{courseId}/board |
| Dashboard tiles | GET /api/v3/dashboard |
| Tasks | GET /api/v3/tasks, GET /api/v3/tasks/finished |
| Lesson body | GET /api/v3/lessons/{lessonId} |
| Lesson's tasks | GET /api/v3/lessons/{lessonId}/tasks |
| Board structure | GET /api/v3/boards/{boardId} |
| What a board belongs to | GET /api/v3/boards/{boardId}/context |
| Card bodies | GET /api/v3/cards?ids=<id>&ids=<id> |
| Files of an entity | GET /api/v3/file/list/{storageLocation}/{storageLocationId}/{parentType}/{parentId} |
| One file's metadata | GET /api/v3/file/{fileRecordId} |
| File bytes | GET /api/v3/file/download/{fileRecordId}/{fileName} |
| News | GET /api/v3/news |
| Instance settings (no auth) | GET /api/v3/config/public |
| Remaining idle budget | POST /api/v3/authentication/refresh-session → {expiresInSeconds} |
Gotchas that cost real time
course-rooms, not courses, for course contents. GET /api/v3/courses/{id}
does not exist. The route that returns a course's lessons/tasks/boards is
GET /api/v3/course-rooms/{roomId}/board, and its :roomId is the course id.
Nothing in the naming suggests this.
/api/v3/rooms is a different feature. "Rooms" are the newer standalone
collaboration spaces, unrelated to courses. On this instance the account has
none, so GET /api/v3/rooms returns {"data":[]} — which reads like a broken
endpoint but is simply an empty feature.
limit maxima are enforced and mis-documented. The OpenAPI schema says
maximum: 99; the runtime validator rejects anything > 100. Page at 99 to
satisfy both. Asking for 200 returns a 400 API_VALIDATION_ERROR, not a
truncated list.
There is no GET /tasks/{id}. Single-task detail has to be assembled: the
list endpoints give metadata but omit description, which appears only on the
course page's task element. get_task does this join.
Board files need three calls. A file element's content carries only
{caption, alternativeText} — no file id. The bytes are found by listing
files-storage with parentType: 'boardnodes' and the element id as
parentId. This is the single least discoverable part of the API, and applies
equally to fileFolder and drawing elements.
storageLocationId is the school id (from /me), with
storageLocation: 'school', for every parent type in normal use.
Lesson ids come back as buffers. GET /api/v3/lessons/{id} returns nested
ids as {buffer:{type:'Buffer',data:[...]}} rather than hex strings — a leak
from the legacy Mongo serialisation. normalizeObjectId in src/render.ts
converts them.
The JWT's exp is not the session lifetime. A server-side whitelist entry
in Valkey (jwt:{accountId}:{jti}) expires after JWT_TIMEOUT_SECONDS — 7200 s
on this instance — and every authenticated request re-sets it. Two hours idle
and the token is rejected with 29 days still on exp. The live values are
public at GET /api/v3/config/public. Full write-up in docs/AUTH.md.
GET /api/v3/config/public is unauthenticated and useful. 78 keys of
instance configuration, including the session timeouts and feature flags. Handy
for checking deployed settings without a token.
Content-Disposition on downloads is malformed. It comes back as
attachment;; filename="…" — note the doubled semicolon — and the filename is
percent-encoded inside the quotes. Parse defensively.
Content element types
From ContentElementType in schulcloud-server, all seen live except where
noted: richText, file, fileFolder, link, drawing,
collaborativeTextEditor, externalTool, videoConference, h5p, deleted.
Collaborative text editor contents are not retrievable through the API —
GET /api/v3/collaborative-text-editor/{parentType}/{parentId} returns a URL to
the Etherpad-style editor, not the document text.
Re-verifying after an upstream release
npm run probe re-checks every assumption above against the live instance and
prints what it finds — both token clocks included: days until hard expiry, and
seconds of idle budget remaining.