Every route, as plain request → response. Base URL in dev: http://localhost:4000.
Two kinds of auth token, both sent as a header:
Authorization: Bearer <token>
- Participant token — returned by
POST /auth/login. Works on/exam/*. - Admin token — returned by
POST /admin/login. Works on/admin/*.
Every error response has the same shape:
{ "error": "human-readable message" }Rate limits: login routes 10/min per IP, all /exam/* routes 300/min, everything else unlimited.
Liveness probe. No auth.
Response 200
{ "status": "ok", "service": "wcl-api", "time": "2026-07-07T10:00:00.000Z" }Server time, used by the client to compute its clock offset. No auth.
Response 200
{ "serverTime": "2026-07-07T10:00:00.000Z" }The client calls these in order: login → begin → manifest → (answer/heartbeat loop) → submit → result. resume replaces begin+manifest after a relaunch.
Authenticate a participant and create (or reuse) their exam session. No auth needed.
Request
{
"username": "user001",
"password": "password",
"examId": "WCL-EXAM",
"deviceId": "3f9c...ab"
}examId optional (defaults to WCL-EXAM). deviceId optional — the Electron client always sends its hardware fingerprint; the session becomes bound to it.
Response 200
{
"token": "eyJhbGciOi...",
"sessionId": "d3b0...",
"sessionStatus": "not_started",
"exam": {
"examId": "WCL-EXAM",
"title": "WCL Practice Examination",
"durationSeconds": 3600,
"questionsToServe": 60,
"instructions": ["...", "..."]
}
}sessionStatus is one of not_started | in_progress | submitted | auto_submitted.
Errors
| Status | When |
|---|---|
401 |
Wrong username/password, or the exam doesn't exist |
403 |
Exam is closed or outside its availability window |
409 |
Already submitted, or session is bound to a different device ("Session is bound to another device. Ask a proctor to release the device binding.") |
Start the clock. Idempotent — calling it again returns the same deadline. Participant token required. Empty body.
Response 200
{
"startedAt": "2026-07-07T10:00:00.000Z",
"deadlineAt": "2026-07-07T11:00:00.000Z",
"serverTime": "2026-07-07T10:00:00.100Z",
"durationSeconds": 3600,
"status": "in_progress"
}The candidate's served questions (subset of the bank, shuffled per session; options shuffled per question). Each question may carry an imageUrl (an admin-uploaded URL) or null. Never contains isCorrect. Participant token required.
Response 200
{
"examId": "WCL-EXAM",
"shuffleSeed": "6f0e...",
"questions": [
{
"questionId": "Q-001",
"type": "SCQ",
"text": "Which planet is known as the Red Planet?",
"imageUrl": null,
"marks": 1,
"options": [
{ "optionId": "O-003", "text": "Mars" },
{ "optionId": "O-001", "text": "Venus" }
]
}
]
}Errors: 409 if /exam/begin hasn't been called yet.
Save one answer. Idempotent upsert — a replay of the same clientSeq or an older one is ignored (returned un-acked), so the offline buffer can resend freely. Participant token required.
Request
{
"questionId": "Q-001",
"selectedOptionIds": ["O-003"],
"status": "answered",
"clientSeq": 17,
"answeredAt": "2026-07-07T10:05:30.000Z"
}status is the palette state: not_answered | answered | marked_for_review | answered_marked. clientSeq must increase with every local change — the server rejects stale writes. answeredAt is when the candidate answered (client clock), not when the request arrived; deadline enforcement judges this timestamp.
Response 200
{ "acked": ["Q-001"] }acked is empty when the write was rejected (stale clientSeq, or answeredAt past deadline + 10s grace).
Periodic sync: pushes any buffered answers, gets the authoritative clock back. Also the trigger that auto-submits a session past its deadline. Participant token required.
Request (both fields optional — {} is a pure clock check)
{
"answers": [
{ "questionId": "Q-002", "selectedOptionIds": ["O-007"], "status": "answered", "clientSeq": 18, "answeredAt": "2026-07-07T10:06:00.000Z" }
],
"integrityEvents": [
{ "type": "focus_lost", "meta": { "count": 3 } }
]
}integrityEvents (max 50) lets queued proctoring events piggyback on the heartbeat instead of costing their own requests; the client coalesces repeats of the same type into one entry with a count.
Response 200
{
"serverTime": "2026-07-07T10:06:01.000Z",
"remainingSeconds": 3239,
"deadlineAt": "2026-07-07T11:00:00.000Z",
"status": "in_progress",
"acked": ["Q-002"]
}If the deadline has passed, status comes back auto_submitted and remainingSeconds is 0 — the client shows the locked screen.
Finish the exam. Idempotent — a second call returns the already-final status. Grades server-side; never returns the score. Participant token required. Empty body.
Response 200
{ "status": "submitted", "submittedAt": "2026-07-07T10:45:12.000Z" }The candidate's own graded result: the final score plus a per-question review of what they picked and how it scored. Available immediately after submit — results publishing is not enforced here. Never exposes the correct answers — only the outcome and marks per question. Options are listed in the same shuffled order the candidate saw. Participant token required.
Response 200
{
"sessionId": "d3b0...",
"examId": "WCL-EXAM",
"status": "submitted",
"submittedAt": "2026-07-07T10:45:12.000Z",
"score": 41.5,
"maxScore": 60,
"correct": 42,
"wrong": 10,
"unanswered": 8,
"questions": [
{
"questionId": "Q-001",
"type": "SCQ",
"text": "Which planet is known as the Red Planet?",
"imageUrl": null,
"marks": 1,
"options": [
{ "optionId": "O-003", "text": "Mars" },
{ "optionId": "O-001", "text": "Venus" }
],
"selectedOptionIds": ["O-003"],
"outcome": "correct",
"marksAwarded": 1
}
]
}outcome is correct | wrong | unanswered; marksAwarded is +marks when
correct, -0.5 when wrong, and 0 when unanswered (so score can be negative).
Errors
| Status | When |
|---|---|
409 |
Exam not submitted yet ("Exam not submitted") |
409 |
Submitted but grading hasn't landed yet ("Result not ready") — retry shortly |
Full state rehydration after a relaunch or crash: manifest, all saved answers, and the live clock in one call. Participant token required. Empty body.
Response 200
{
"exam": { "examId": "WCL-EXAM", "title": "...", "durationSeconds": 3600, "questionsToServe": 60, "instructions": ["..."] },
"manifest": { "examId": "WCL-EXAM", "shuffleSeed": "...", "questions": [ ... ] },
"answers": [
{ "questionId": "Q-001", "selectedOptionIds": ["O-003"], "status": "answered", "clientSeq": 17, "answeredAt": "2026-07-07T10:05:30.000Z" }
],
"deadlineAt": "2026-07-07T11:00:00.000Z",
"remainingSeconds": 3100,
"serverTime": "2026-07-07T10:08:20.000Z",
"status": "in_progress"
}If the deadline passed while the client was away, the session is finalized first and status is auto_submitted.
Submit post-examination feedback. Participant token required, and the session
must be submitted or auto_submitted (otherwise 409 Exam not submitted).
Ratings are integers from 1 to 5; the comment is optional (max 1000
characters). Only the first submission per session is stored; repeats still
return 200.
Request
{ "platformRating": 4, "infrastructureRating": 5, "comment": "Smooth experience" }Response 200
{ "ok": true }Report a proctoring event (the client sends these on focus loss etc.). Participant token required.
Request
{ "type": "focus_loss", "meta": { "durationMs": 4200 } }Response 200
{ "ok": true }All routes below are prefixed /admin and (except login) require an admin token.
Request
{ "email": "admin@wcl.local", "password": "adminpass", "totp": "123456" }totp is required only after the admin has run MFA setup.
Response 200
{ "token": "eyJhbGciOi...", "email": "admin@wcl.local" }Errors: 401 — bad credentials, missing TOTP ("TOTP code required"), or wrong TOTP.
Generate and enable a TOTP secret for the logged-in admin. Empty body.
Response 200
{ "secret": "JBSWY3DP...", "otpauthUrl": "otpauth://totp/WCL:admin@wcl.local?secret=..." }Scan otpauthUrl in an authenticator app; from then on login requires totp.
Paged leaderboard from Redis (rebuilt from the DB automatically if empty).
Response 200
{
"examId": "WCL-EXAM",
"total": 700,
"entries": [
{ "rank": 1, "participantId": "a1b2...", "username": "user042", "displayName": "Jane Doe", "score": 58 }
]
}Live monitoring: status counts + the 200 most recent sessions.
Response 200
{
"counts": { "not_started": 12, "in_progress": 640, "submitted": 40, "auto_submitted": 8 },
"sessions": [
{
"sessionId": "d3b0...",
"username": "user001",
"status": "in_progress",
"startedAt": "2026-07-07T10:00:00.000Z",
"deadlineAt": "2026-07-07T11:00:00.000Z",
"submittedAt": null
}
]
}All graded results, newest first.
Response 200
[
{
"sessionId": "d3b0...",
"username": "user001",
"examId": "WCL-EXAM",
"status": "submitted",
"score": 42,
"maxScore": 60,
"correct": 42,
"wrong": 10,
"unanswered": 8,
"startedAt": "2026-07-07T10:00:00.000Z",
"submittedAt": "2026-07-07T10:45:12.000Z",
"gradedAt": "2026-07-07T10:45:12.500Z"
}
]Per-candidate answer review — every served question with the correct options and what the candidate picked.
Response 200
{
"sessionId": "d3b0...",
"username": "user001",
"examId": "WCL-EXAM",
"status": "submitted",
"score": 42,
"maxScore": 60,
"startedAt": "2026-07-07T10:00:00.000Z",
"submittedAt": "2026-07-07T10:45:12.000Z",
"answers": [
{
"questionId": "Q-001",
"type": "SCQ",
"text": "Which planet is known as the Red Planet?",
"imageUrl": null,
"options": [
{ "id": "O-001", "text": "Venus", "isCorrect": false },
{ "id": "O-003", "text": "Mars", "isCorrect": true }
],
"selectedOptionIds": ["O-003"],
"outcome": "correct"
}
]
}outcome is correct | wrong | unanswered. Errors: 404 unknown session.
Edit a final score (audited as result.score_edit; leaderboard and live WS update immediately).
Request
{ "finalScore": 45, "reason": "question Q-017 thrown out" }finalScore may be any number — fractional and negative values are accepted
(negative marking can push a raw score below zero).
Response 200 — the updated result row (same shape as one entry of GET /admin/results).
Errors: 404 — no graded result for that session yet.
CSV download of the results list. Response is text/csv, columns: Username, Exam, Status, Score, Max score, Correct, Wrong, Unanswered, Started at, Submitted at.
CSV download of the full leaderboard (all ranked entries, not paged). Response is text/csv, columns: Rank, Username, Name, Score.
Wipe a session back to not_started: deletes its answers and result, clears deadline/seed, removes the leaderboard entry. Audited. Empty body.
Response 200
{ "ok": true }Clear the device binding so the candidate can log in from a different machine (hardware failure, seat move). Their next login rebinds to the new device and logs a device_change event. Audited. Empty body.
Response 200
{ "ok": true }Extend one candidate's deadline. Pushed live to a connected client over pub/sub. Audited.
Request
{ "seconds": 300 }Response 200
{ "ok": true, "deadlineAt": "2026-07-07T11:05:00.000Z" }Errors: 409 — session isn't in_progress.
Extend every in-progress session of an exam (and the exam's availableUntil, if set).
Request
{ "seconds": 300 }Response 200
{ "ok": true, "updated": 640 }Open or close the exam for login. Audited. Empty body.
Response 200
{ "ok": true, "isOpen": true }Toggle the results-published flag.
Request
{ "published": true }Response 200
{ "ok": true, "resultsPublished": true }Integrity events, newest first. Both filters optional. Types seen in practice: focus_loss, double_login, device_change.
Response 200
[
{
"id": "e91c...",
"sessionId": "d3b0...",
"username": "user001",
"type": "focus_loss",
"meta": { "durationMs": 4200 },
"createdAt": "2026-07-07T10:12:00.000Z"
}
]Full question bank including answers — admin-only.
Response 200
[
{
"id": "Q-001",
"type": "SCQ",
"text": "Which planet is known as the Red Planet?",
"imageUrl": null,
"marks": 1,
"options": [
{ "id": "O-001", "text": "Venus", "isCorrect": false },
{ "id": "O-003", "text": "Mars", "isCorrect": true }
]
}
]Create or update questions (upsert by id; omit id to create). Options are replaced wholesale. Validates SCQ has exactly one correct option, MCQ at least one. Busts the Redis bank cache. Audited.
Request
{
"examId": "WCL-EXAM",
"questions": [
{
"type": "MCQ",
"text": "Which of these are prime?",
"marks": 2,
"options": [
{ "text": "2", "isCorrect": true },
{ "text": "3", "isCorrect": true },
{ "text": "4", "isCorrect": false }
]
}
]
}Response 200
{ "ok": true, "ids": ["Q-a1b2c3d4"] }Each question may include an optional imageUrl (a URL returned by
POST /admin/upload, or null to clear it); it is echoed back by
GET /admin/questions, the candidate manifest, and the result review.
Delete a question and its options. Refused if any session was ever served it.
Response 200: { "ok": true } · Errors: 404 unknown, 409 already served.
Upload a question image. The body is the raw image bytes (not multipart); set
Content-Type to the image's own type — one of image/png, image/jpeg,
image/webp, image/gif. Max 5 MB. The file is stored in the configured
S3-compatible bucket (Floci locally, real S3 in production); put the returned
url in a question's imageUrl. Audited.
Request
Content-Type: image/png
<raw image bytes>
Response 200
{ "url": "http://localhost:4566/wcl-images/q/2f1c8e4a-....png" }Errors: 400 — missing body, unsupported content-type, or larger than 5 MB.
All participants, sorted by username.
Response 200
[
{ "id": "a1b2...", "username": "user001", "displayName": "Candidate 001", "dob": "2001-04-12", "createdAt": "2026-07-07T09:00:00.000Z" }
]dob (YYYY-MM-DD, or null) is used by the external hall-ticket portal
(username + dob login) and shown in admin; it plays no part in exam login.
Hall-ticket seat allocations (participants ⋈ hallticket_seats), sorted by
username. Load rows with bun run import:seats in app/api.
Response 200
[
{ "id": "a1b2...", "username": "user001", "displayName": "Candidate 001", "dob": "2001-04-12", "blockNo": "Digital Tower", "floorNo": "Ground Floor", "labNo": "Lab 1", "seatNo": "A-001" }
]Bulk import (max 1000 per call). secret and dob are optional: a row
without a secret gets the common exam password from PARTICIPANT_PASSWORD
(default wclrbu2026), and dob is "YYYY-MM-DD", stored and shown in admin
only (for a future external hall-ticket site) — it plays no part in login.
Secrets are argon2id-hashed on ingest; existing usernames are skipped,
duplicates within the batch collapse to the first. Audited.
Request
{
"participants": [
{ "username": "user701", "displayName": "New Candidate", "dob": "2003-11-02" },
{ "username": "user702", "secret": "s3cret", "displayName": "Own Password" }
]
}Response 200
{ "created": 1, "skipped": 0 }Read-only live feed. The upgrade is rejected unless the token is a valid admin JWT. Each message is:
{ "channel": "wcl:leaderboard:WCL-EXAM", "payload": { "participantId": "...", "username": "user042", "score": 58, "maxScore": 60, "submittedAt": "..." } }Channels: wcl:leaderboard:<examId> (score changes — new grades and admin edits) and wcl:session:<sessionId> (add_time pushes).