API wrapper for SchulNetz portals (KASCHUSO + SAL).
This service exposes a small HTTP API to authenticate against upstream portals and fetch:
- user info
- grades
- unconfirmed grades
- absences
- mandators (best effort, see caveats)
- portal metadata for frontend bootstrapping
- Overview
- Requirements
- Quick Start
- Configuration
- Run Modes
- API Endpoints
- Example Calls
- Response Shapes
- Troubleshooting
- Development
- Docker
- Security Notes
- Publish Gate
This project is an Express server (app.js) with route handlers in routes/api/ and scraping logic in services/kaschuso-api.js.
- API Base URL: https://kaschuso-api.onrender.com
- Deployment: Render (free tier, auto-deploys on GitHub
mainbranch push) - Frontend Consumer: Cloudflare Pages
- Default address:
http://localhost:3001
- Node.js 20+ (project Dockerfile uses Node 20-alpine)
- npm or yarn
- Internet access to
https://kaschuso.so.ch/ - Optional SAL access to
https://portal.sbl.ch/when enabled
- Install dependencies.
yarn installor
npm install- Create a local env file.
cp .env.default .envFor production deployments, start from the production template:
cp .env.production.example .env- Start the server.
yarn startor
npm start- Verify the API is reachable.
curl -i http://localhost:3001/healthConfig is loaded via dotenv.
Available variables:
PORT: server port (default3001)KASCHUSO_BASE_URL: base URL (defaulthttps://kaschuso.so.ch/)SAL_BASE_URL: SAL base URL (defaulthttps://portal.sbl.ch/)ENABLE_SAL_PORTAL: enable SAL mandators in API responses (defaulttrue)FRONTEND_ORIGIN: allowed CORS origin (required in production)JWT_SECRET: token signing secret (required in production)API_SESSION_TTL_SECONDS: token/session lifetime (default900)API_RATE_LIMIT_WINDOW_MS: global rate-limit window (default60000)API_RATE_LIMIT_MAX: global rate-limit max requests (default120)API_AUTH_RATE_LIMIT_WINDOW_MS: auth route rate-limit window (default300000)API_AUTH_RATE_LIMIT_MAX: auth route rate-limit max requests (default10)
Example .env:
PORT=3001
KASCHUSO_BASE_URL=https://kaschuso.so.ch/
SAL_BASE_URL=https://portal.sbl.ch/
ENABLE_SAL_PORTAL=true
FRONTEND_ORIGIN=https://app.example.com
JWT_SECRET=change-meLocal frontend example (Vite):
FRONTEND_ORIGIN=http://localhost:5173,http://127.0.0.1:5173Production template: .env.production.example
Start production mode:
yarn startStart dev mode with auto-reload:
yarn devRun tests:
yarn testStop process on port 3001:
npm run stopThe API implements secure upstream session management for two portal types:
- Session Bootstrap: A
basicAuthenticate()call initializes a session cookie (SCDID_S). - CSRF Handling: Login form page and
ses.js(containing BID tokens) are fetched with session cookie. - Form Submission: All form inputs are scraped and submitted as POST with:
- Accumulated session cookies
- Browser-like headers (
Origin,Referer) - BID parameter from
ses.js(CSRF token)
- Session Validation: On success,
SCDID_Scookie is returned and validated.
- Curl-based Auth: Uses curl with persistent cookie jar to authenticate against
/my.policy. - Session Capture: Captures SimpleSAML and MRHSession cookies through SAML redirect flow.
- Lazy Fetch: Homepage and protected pages are fetched only when needed, avoiding destructive re-requests.
- Automatic Retry: If homepage returns a logout shell, automatically re-authenticates and retries once.
The /api/authenticate endpoint returns the following failure reasons:
INVALID_CREDENTIALS: The upstream login returned HTTP 200 with a credential error message inline (German:"Zugriff verweigert..."), OR a 302 redirect indicating credentials were processed but rejected.UPSTREAM_RESPONSE_CHANGED: The upstream login returned HTTP 200 but no session cookie and no credential error message — format may have changed.UPSTREAM_UNAVAILABLE: HTTP 5xx from upstream.NETWORK_ERROR: Network/connectivity issue (e.g., no route to host, timeout).UPSTREAM_TIMEOUT: Request exceededaxiostimeout ormaxRedirects.UPSTREAM_FORBIDDEN: HTTP 403 from upstream (IP block, too many requests, etc.).
Base path: /api
Simple readiness/liveness probes for local and hosted deployments.
Example:
curl -i http://localhost:3001/healthReturns a list of available mandators.
The endpoint first parses https://kaschuso.so.ch/robots.txt (live source) and merges those slugs with local curated metadata.
If robots data is unavailable, it falls back to scraping the public landing page and then to the curated fallback list.
Known alias slugs are normalized to canonical values in API output (for example kbssogr -> kbsso) so clients get stable IDs.
Note: The result is best effort. It is intended to stay useful after the upstream removed most public school links, but newly added schools or renamed mandators may still need a code update.
When ENABLE_SAL_PORTAL=true (default), SAL mandators like gymli (Gymnasium Liestal) are included in the mandator list. SAL uses a modern F5 portal authentication flow and requires special session handling to avoid destructive probes.
Returns publish/runtime metadata for frontend bootstrap and environment diagnostics.
Includes:
- feature flags (for example
salPortal) - configured upstream base URLs
- effective mandator list (same source as
/api/mandators)
Example:
curl 'http://localhost:3001/api/meta'JSON body:
mandatorusernamepassword
Returns whether credentials are valid and issues a short-lived bearer token.
Headers:
Authorization: Bearer <token>
Returns user profile information.
Headers:
Authorization: Bearer <token>
Returns parsed subjects and grades.
Headers:
Authorization: Bearer <token>
Returns the homepage Ihre letzten Noten feed, representing grades that are still awaiting user confirmation and acting as a latest-uploaded overview.
Headers:
Authorization: Bearer <token>
Returns absence entries. Response structure is unified across KASCHUSO (legacy) and SAL (modern F5-based) portals, but data extraction and availability differ:
- KASCHUSO: Full support for absences, tardiness, open reports, incident details, and contingent tracking.
- SAL (GymLi): Absences and tardiness supported; parser is resilient to nested colspan rows and varying table column layouts; incident details extraction handles both nested tables and text-based formats; contingent tracking depends on portal field visibility.
Authenticate:
curl -X POST 'http://localhost:3001/api/authenticate' \
-H 'Content-Type: application/json' \
-d '{"mandator":"YOUR_MANDATOR","username":"YOUR_USERNAME","password":"YOUR_PASSWORD"}'Get grades:
curl 'http://localhost:3001/api/grades' \
-H 'Authorization: Bearer YOUR_TOKEN'Get unconfirmed grades:
curl 'http://localhost:3001/api/unconfirmed-grades' \
-H 'Authorization: Bearer YOUR_TOKEN'Get absences:
curl 'http://localhost:3001/api/absences' \
-H 'Authorization: Bearer YOUR_TOKEN'Get user info:
curl 'http://localhost:3001/api/user/info' \
-H 'Authorization: Bearer YOUR_TOKEN'Get mandators:
curl 'http://localhost:3001/api/mandators'Get API metadata:
curl 'http://localhost:3001/api/meta'Authenticate success:
{
"mandator": "gibsso",
"username": "your.user",
"authenticated": true,
"token": "<jwt>",
"expiresIn": 900
}Authenticate failure:
{
"mandator": "gibsso",
"username": "your.user",
"authenticated": false,
"reason": "AUTHENTICATION_FAILED",
"detail": "The upstream login did not accept the provided credentials."
}Grades response:
{
"mandator": "example-school",
"username": "student.username",
"subjects": [
{
"class": "CLASS_CODE",
"name": "Subject Name",
"average": "5.0",
"grades": [
{
"date": "01.01.2026",
"name": "Exam 1",
"value": "5.5",
"points": "18",
"weighting": "1",
"average": "5.2"
}
]
}
]
}Unconfirmed grades response:
{
"mandator": "example-school",
"username": "student.username",
"grades": [
{
"subject": "COURSE-CODE",
"name": "Assessment title",
"date": "01.01.2026",
"value": "5.5"
}
]
}Absences response:
{
"mandator": "example-school",
"username": "student.username",
"absences": [
{
"date": "29.01.2026",
"untilDate": "30.01.2026",
"reason": "Krankheit",
"status": "Absence points",
"subject": "Mathematik",
"points": "2",
"period": "2026-01-29 to 2026-01-30",
"details": [
{
"date": "29.01.2026",
"time": "14:15",
"course": "Mathematik Lektion 1"
}
]
}
],
"openReports": [
{
"date": "28.01.2026",
"untilDate": "28.01.2026",
"reason": "Open report",
"status": "Open report",
"subject": "",
"period": "2026-01-28"
}
],
"tardiness": [
{
"date": "27.01.2026",
"reason": "Excused",
"status": "Tardiness (Excused)"
}
],
"incidents": [
{
"date": "29.01.2026",
"period": "2 pts",
"details": [
{
"date": "29.01.2026",
"time": "14:15",
"course": "Mathematik Lektion 1"
}
]
}
],
"contingentUsed": "4",
"contingentRemaining": "13"
}Portal differences:
- Both KASCHUSO and SAL return
absences[],openReports[],tardiness[],incidents[]in a unified shape. - KASCHUSO populates all fields reliably across portal versions.
- SAL (GymLi) may omit
pointsordetails[]if absences lack point-based classification; incident rows may appear with different column orderings due to F5 table rendering variations. - Tardiness counter (
excused/unexcused): Parser includes fallback logic for both portals—if summary counters are "0 | 0" but tardiness rows exist with "Ja"/"Nein" markers, row data is parsed directly.
Field meanings:
absences[]: Individual absence entries (each may represent a date range).openReports[]: Reports awaiting submission or confirmation.tardiness[]: Late arrivals (excused/unexcused).incidents[]: Point-based absence incidents with optional recorded lessons underdetails[].AbsenceDetailEntry(indetails):{ date, time, course }— lesson recorded under an incident.points: Contingent points consumed by the absence (may be null).contingentUsed/contingentRemaining: Summary of absence point budget.
- Verify
mandator, username, and password. - Ensure you call
POST /api/authenticatewith a JSON body.
Run this checklist before public deployment.
- Dependency and runtime checks.
yarn install
yarn audit --groups dependencies
yarn test --runInBandExpected:
0 vulnerabilities foundin dependency audit- all tests pass
- Production startup smoke test.
PORT=3001 NODE_ENV=production JWT_SECRET=replace-me FRONTEND_ORIGIN=https://app.example.com timeout 15s yarn startExpected:
- server starts with
Listening on port 3001
- Live health checks.
curl -sS http://localhost:3001/health
curl -sS http://localhost:3001/api/healthExpected payload for both:
{"status":"ok"}- Authorization guard checks.
curl -i http://localhost:3001/api/gradesExpected:
401response withUNAUTHORIZED
- Environment requirements for production.
NODE_ENV=productionJWT_SECRETmust be setFRONTEND_ORIGINmust be set to your frontend origin- Prefer HTTPS at ingress/reverse proxy with HSTS enabled
- Container build (where Docker is available).
docker build -t kaschuso-api:release .Expected:
- image builds without errors on Node 20 runtime
- Ensure authentication is true first.
- Upstream HTML can change. This project includes parser support for legacy and newer KASCHUSO layout, but future upstream changes can still break scraping.
- Some pages can contain both legacy and modern grade table structures at once. Parser logic should evaluate both and prefer the richer result (more subjects/grades) to avoid partial lists.
- Current upstream often no longer exposes mandator links publicly.
- The API now discovers slugs primarily from
robots.txtand merges them with curated labels. - If your school is missing, use the known mandator directly from your school docs and add it to the fallback list in
services/kaschuso-api.js. - For SAL rollout,
gymliis exposed through curated config whenENABLE_SAL_PORTAL=true.
- Some upstream slugs are aliases and are normalized in API output (for example
kbssogris returned askbsso). - Prefer storing and reusing the canonical slug values returned by
/api/mandators.
- Confirm access to
https://kaschuso.so.ch/. - Check proxy variables (
http_proxy,https_proxy,no_proxy). - Retry later if upstream rate limits or blocks suspicious traffic.
- Ensure
FRONTEND_ORIGINincludes the exact frontend origin (protocol + host + port). - If you use both host variants locally, include both:
http://localhost:5173,http://127.0.0.1:5173. - Restart the backend after changing
.env. - Browser CORS checks require
Access-Control-Allow-Originto match a single request origin exactly.
- Verify app process is alive with
curl -i http://localhost:3001/health.
- SAL authentication succeeds but protected pages return error: This typically indicates the session was invalidated by re-requesting the Webtop or homepage immediately after auth. The service now avoids this via careful session handling.
- If logout appears in response after successful auth, the session context was not preserved properly. The service automatically re-authenticates once if this is detected.
- Empty user info or missing fields: SAL's schulNetz may expose fewer fields depending on school configuration (e.g.,
address,educationmay be empty). - User info label variants are supported for SAL profile tables (for example
Name Vorname,Strasse,PLZ Ort,Profil), so parsing stays resilient across school-specific field labels. - Check that
ENABLE_SAL_PORTAL=truein.env(default yes). ConfirmSAL_BASE_URL=https://portal.sbl.ch/is set.
- Do not rely only on detail row classes ending with
_detailrow; some pages use different class names while still embedding validtable.cleangrade rows. - Detail tables may include header/label pseudo-rows (
Datum,Thema,Bewertung,Gewichtung) rendered as regular<td>rows and these must be skipped during grade extraction. - Grade value cells can include info icons/tooltips (
Details zur Note,Punkte) inside<i>/<span>markup. Do not treat presence of<i>tags alone as a header-row signal.
Project layout:
app.js: Express app bootstraproutes/api/*.js: HTTP route handlersservices/kaschuso-api.js: scraping/authentication logic__test__/: fixtures and parser tests
Fixture guidance:
- Keep fixtures privacy-safe and replace personal names, teacher-coded class identifiers, contact details, credentials, birth dates, postal codes (PLZ/ZIP), and hometown values with generic placeholders whenever they are not required for parser coverage.
Run tests:
yarn testBuild image:
docker build -t kaschuso-api .Run container:
docker run --rm -p 3001:3001 --env-file .env kaschuso-api- Credentials are accepted only via
POST /api/authenticateJSON body. - Protected endpoints require
Authorization: Bearer <token>. GET /api/unconfirmed-gradesfollows the same bearer-token protection as the other protected data endpoints.- Do not send credentials in query parameters.
- Always deploy behind HTTPS and set
JWT_SECRET+FRONTEND_ORIGINin production. - Do not commit secrets or real credentials.
This project is a rebuilt and fixed version of the original KASCHUSO API wrapper.
Original source: