docs/API.md gains the findings: the endpoint and its required version parameter, the one-time code and the two error codes worth naming, the Z that means local time, a substitution being two periods, the unused exam module that puts announced tests in the period notes, and a day without lessons that is not a holiday. Those cost an afternoon of probing to learn and nothing upstream states them. docs/AUTH.md sets the key against the Schulcloud token it sits beside: no password, nothing to keep alive, revocable on its own, and not read-only in itself — which is why the allowlist exists. PI.md and DEPLOYMENT.md add the four values, the container recreate a changed key needs, and the clock requirement; the Pi's troubleshooting table gains both failure messages. README and CLAUDE.md say what the server now is: Schulcloud for the material, WebUntis for the day. Smoke is 89 checks with the index and a key, 87 live-only, 9 fewer without one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 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, and the UI's naming hides it. Rooms
("Räume") are the newer standalone collaboration spaces. The sidebar's Kurse
entry links to /rooms/courses-overview and lists courses (served by
/api/v3/dashboard + /api/v3/courses), while Räume links to /rooms and
lists rooms — so a url containing /rooms identifies neither.
A room holds boards and nothing else: no lessons, no tasks. GET /rooms
answers {"data":[]} with no total, derived from real memberships, so an
empty result means the account is in no rooms — which is also what it looks
like after a teacher deletes a room or revokes access. GET /rooms/{id}/boards
does report isVisible, unlike the course-page projection, so a room's draft
boards can be identified without trying to open one.
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.
GET /cards?ids= accepts at most 20 ids. Above that the request fails with
400 "each value in ids must be a mongodb id" — blaming the ids when the real
problem is how many there are. Express/NestJS parse the query string with qs,
whose default arrayLimit is 20; past it, repeated params become an object
keyed "0", "1", … and @IsMongoId({ each: true }) then rejects every value.
Nothing in the controller or its DTO says so: the limit lives in the query
parser underneath them. Verified live — 20 ids return 200, 21 return 400 with
identical ids. A board with more than 20 cards is therefore unreadable in one
request; getCards chunks at 20.
Submissions are nearly invisible. The only route is
GET /api/v3/submissions/status/task/{taskId} — there is no GET /submissions
and no fetch-by-id, so a task id is the only way to reach a submission. The
response carries {id, submitters, isSubmitted, isGraded, grade, submittingCourseGroupName} and nothing else: no submitted text, no grade
comment, no graded-at. Those lived on the legacy Feathers API, and /api/v1 is
not served on this instance (404 across the board), so they are simply
unavailable. Submitted files are reachable through files-storage with
parentType: 'submissions'.
Submitted text and written feedback exist only in the rendered web page.
The legacy front end calls the Feathers API server-side for
submission.comment, submission.grade and submission.gradeComment and
renders them into GET /homework/{taskId}. That Feathers API is not exposed
publicly (/api/v1/* 404s), so the page is the only way to reach these from
outside. core/homework-page.ts parses it, hooked on the data-testid
attributes the project's own e2e tests use. Note the page authenticates with the
jwt cookie — an Authorization header is ignored and redirects to the
identity provider. Measured: 4 of 7 graded submissions in one course carried
feedback no API call can return.
A grade is a percentage or nothing. The submission schema is
grade: { type: Number, min: 0, max: 100 } and gradeComment: { type: String }
— there is no textual grade field. In practice teachers frequently grade with
gradeComment alone and leave grade unset, so a written "OK" is the whole
verdict for that submission. Treat a missing grade as "no percentage given",
never as "ungraded" (graded is its own boolean) and never as an error.
files-storage ignores parentType when listing. Asking for
.../gradings/{submissionId} returns the files parented to that id whatever
their type — the records come back saying parentType: "submissions". The path
segment appears to serve authorisation, not filtering, so filter on each
record's own parentType or a student's own upload will be reported back as
teacher feedback.
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, and the source misleads here.
Both the current and legacy whitelist implementations re-set a Valkey TTL on
every authenticated request, which reads as a sliding window. The live instance
does not behave that way: a session ends ~2 h after login, and successful
reads in between do not extend it (measured — see docs/AUTH.md). This is the
clearest case in this API of live behaviour diverging from upstream source.
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.
GET /lessons/{id}/tasks returns a bare array, and its items have no id.
Every other list endpoint returns {data, total}; this one returns the array
directly, so reading .data silently yields undefined. Worse, the items are
LessonLinkedTaskResponse, which has no id property at all — name, description
and dates only. A task attached to a topic is therefore unidentifiable from the
API: it is not a task element on the course page (the topic reports only
numberOfPublishedTasks), and once past due it is in neither /tasks nor
/tasks/finished. On the account this server was built for that hid 18 of 60
tasks, submissions and grades included. The ids are recoverable only from the
legacy topic page, which links each task as /homework/{id} —
core/lesson-page.ts.
A student's task lists exclude past-due tasks. /tasks drops a task once
its due date passes; /tasks/finished holds only what the student ticked off.
A submitted, graded, past-due task is in neither. Reach it through the course
page, or through its topic.
An unpublished board is listed but cannot be opened. The course-board
projection reports a draft board with its title, while GET /boards/{id}
answers 403 for anyone who cannot edit it. Treat a 403 there as "probably not
published yet", not as an access problem.
PATCH /file/rename/{fileRecordId} mutates a file record in place. The id
and size stay the same, so any change detection keyed on those alone misses it.
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 elements come back with content: {} — no pad id, no
url, nothing. GET /api/v3/collaborative-text-editor/content-element/{elementId}
returns the pad url, and also sets an Etherpad sessionID cookie in its
response. With that cookie, Etherpad's own /etherpad/p/{padId}/export/txt
returns the document as plain text. So the contents are reachable, in two
hops and without Etherpad's API key; core/etherpad.ts does this. The url is
built from the server's ETHERPAD__PAD_URI, so it must be checked against the
instance host before the session cookie is sent to it.
The file manager ("Dateien") is a third store
Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien are the
legacy file system: a files collection with real folders (isDirectory,
parent, owner, refOwnerModel), served by the legacy Feathers
fileStorage service. It shares nothing with files-storage. Asking
/api/v3/file/list/school/{school}/courses/{courseId} answers 0 for a
course whose file manager holds dozens of worksheets — measured: 21 of 26
courses on the live account keep files here, some nothing else.
Its service is not in the public ingress, so the only way in is the legacy client. Verified live:
Route (legacy client, jwt cookie) |
Returns | Notes |
|---|---|---|
GET /files/my/ , /files/my/{folder} |
HTML listing | personal root / one folder |
GET /files/courses/ |
HTML listing | the courses, as folders (ids = course ids) |
GET /files/courses/{course} , /files/courses/{course}/{folder} |
HTML listing | one folder segment at any depth |
GET /files/teams/… |
HTML listing | same shape as courses |
GET /files/shared/ |
HTML listing | flat; shared folders cannot be opened (no route; the UI's link 404s) |
GET /files/signedurl?file={id}&name={name} |
{"url": …} |
pre-signed S3 url, another host (live: s3.hidrive.strato.com) |
GET /files/permittedDirectories/ |
JSON tree | lists every course with no folders in any — see below |
GET /files/search/?q= |
HTML | 504 on live: an unindexed regex over every file record |
Listings are parsed on the attributes the page's own scripts use:
data-folder-id with the name inside .card-title-directory (emitted
unescaped, {{{stripOnlyScript name}}}), and data-file-id,
data-file-name, data-file-size, data-file-viewer-type on each
.card.file. A blocked file carries btn-file-danger and no viewer type. No
dates are rendered.
permittedDirectories is broken for courses. The directory service's
query matches course folders on refOwnerModel: 'courses'; the records say
'course'. Live result: 26 courses, 0 folders; personal folders come through.
Listings are the only complete view — which is also what the UI shows.
Some GET routes write. GET /files/share/?file= mints a share token when
the file has none (PATCH /fileStorage/shared/{id}), and
GET /files/file?…&share=… grants the caller a permission on the file.
GET /files/fileModel/{id}/proxy forwards to the latter. A GET-only client is
therefore not read-only against this surface by itself: core/client.ts
allows only the listing routes and /files/signedurl, by pattern.
The signed-url service returns its error instead of throwing it
(.catch((err) => new Forbidden(err))), so a refused file is a 200 whose body
has no url.
Course names contain / in real data ("LF07 - FIA24A/B - Sb/Ha",
"FIA24/FIP24 IT LF12"), and a real file is literally called ..docx. Paths
built from names cannot be split naively; core/legacy-files.ts resolves by
trying joined segments, and accepts ids as segments.
Two more, seen while building the local fixture:
getRefOwnerModel(owner)answers "a course, or elseteams". Any owner id that is not a course — a user included — is recorded as a team's. The upload page never sends an owner for personal files (data-owner=""), so the server defaults to the creator and recordsuser; send the user id and every later permission check dereferences a team that does not exist.- The file permission service writes
refOwnerModelwhere the "shared with me" query readsrefPermModel, so a share made through it never appears under Geteilte Dateien. The share-link flow patches/files/{id}directly.
WebUntis is a fourth store, and holds the timetable
Schulcloud's times on /api/v1/courses are empty for this school, so nothing
in Schulcloud says when a lesson happens, let alone that it was cancelled. That
lives in WebUntis, a separate product with its own login. What this server
uses is the API the Untis Mobile app uses, verified against
ags-erfurt.webuntis.com on 2026-09-17.
- One endpoint, JSON-RPC:
POST /WebUntis/jsonrpc_intern.do?m=<method>&school=<school>&v=i3.2, with{"jsonrpc":"2.0","method":<method>,"params":[{…, "auth":{…}}]}.vis not optional. Omit it and the call fails with-8998carrying a JavaNullPointerExceptionfromgetParameter, which reads like a bug in the request body and is not.- Errors arrive with HTTP 200 and an
errormember, so the body has to be checked before the status. - A plain
user-agent: schulcloud-mcpis accepted; there is no need to impersonate the app.
- Authentication is a TOTP over a static base32 key, no password and no
session:
auth: { user, otp, clientTime }on every call, whereotpis the 6-digit RFC 6238 code for the key from Profil → Freigaben → Untis Mobile.- Send the code as a string. One in ten starts with a zero, which a JSON number silently drops. Both shapes are accepted, so only the string is always right.
-8504 bad credentials= wrong key or user.-8524 invalid client time= the host's clock is off, which is its own failure and worth naming.- The response sets a
JSESSIONID, but nothing needs it: each request authenticates itself, which is why there is no keepalive on this side.
- Methods that exist (and are all this server may call — see the allowlist
in
core/untis.ts):getUserData2017,getTimetable2017,getLessonTopic2017,getHomeWork2017,getMessagesOfDay2017.getClassregEvents2017andgetSchoolyears2017answer "Method not found";getPeriodData2017answers with empty objects for a student. startDateTimeclaims to be UTC and is not. Lessons come back as2026-09-21T08:00Zand the school's time grid starts at 08:00 local, so theZis decoration.new Date(...)would move every lesson by an hour or two, twice a year by a different amount;splitLocaltakes the string apart instead.- A substitution is two periods, not one changed period. The original turns
up with
is: ["CANCELLED"]and the replacement beside it withis: ["IRREGULAR"], same slot, different teacher.orgIdon an element also exists (the room moves that way), so both have to be read. Statuses seen on a real account:REGULAR,CANCELLED,IRREGULAR. getTimetable2017carries the whole master data with every answer — subjects, teachers with full names, rooms, classes, every holiday of six school years. There is amasterDataTimestampdelta protocol; its removal semantics are unverified, so this server asks for the full set and caches it for a few hours.- Homework hangs off the periods too.
getHomeWork2017filters by the homework's own dates, so a window ending today shows nothing due tomorrow and nothing set last month — and the same items appear inline on the timetable's periods, where they need no second call. getLessonTopic2017takesperiodId, singular. It answers withpreviousTopics: what the earlier lessons of that series actually covered, from the class register. AperiodIdsarray is rejected as "period 0 not found".- The exam module is unused at this school, so
getExams2017is empty andperiod.examis null. Announced tests are typed into the period's info text instead ("LF10: Leistungskontrolle agile Softwareentwicklung …"), which makestext.infothe most valuable field in the payload rather than a footnote. - A day with no lessons is not necessarily a holiday. At a vocational school
the weeks spent in the company simply have no periods, and
holidayssays nothing about them. Reporting "Ferien" there would be wrong; so would an empty answer. - The key can write. This account's
rightsareCLASSREGISTER,R_MY_ABSENCES,W_OWN_ABSENCE,R_OFFICEHOURS— the mobile API can report an absence for the user. Nothing here does, and the allowlist is what guarantees it.
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.