Base URL: /api/v1
Interactive docs: http://localhost:8000/docs
All admin endpoints require a Bearer token in the Authorization header:
Authorization: Bearer <access_token>
Obtain tokens via POST /auth/login. Taker endpoints (/sessions/take/..., /sessions/{id}/...) are public — no token required.
POST /auth/login// Request
{"email": "admin@example.com", "password": "secret"}
// Response
{
"access_token": "...",
"refresh_token": "...",
"force_password_reset": false
}
POST /auth/refresh// Request
{"refresh_token": "..."}
// Response
{"access_token": "...", "refresh_token": "..."}
GET /auth/meRequires authentication. Returns the current user: {id, email, role, tenant_id, force_password_reset}.
POST /auth/change-passwordRequires authentication.
{"current_password": "...", "new_password": "..."}
All endpoints require admin or manager role.
GET /questionsList all questions in the tenant’s question bank.
Query params: type, tag, search
GET /questions/{id}Get a single question.
POST /questionsCreate a question.
{
"type": "multiple_choice",
"prompt": "Plain text prompt (auto-wrapped)",
"prompt_json": { ... }, // OR: Tiptap JSON (takes priority)
"options": [{"id": "a", "text": "Option A"}, ...],
"correct_answer": "a", // plain option id (MC/TF); ["a","c"] for MS; {"text":"…"} for short_text
"explanation": "Plain text explanation",
"explanation_json": { ... }, // OR: Tiptap JSON
"points": 1,
"tags": ["grammar"]
}
For audio_prompt / video_prompt, use media_ref instead of options:
{
"type": "audio_prompt",
"prompt": "Listen and answer",
"media_ref": {"media_file_id": "<uuid>", "mime_type": "audio/mpeg"}
}
PUT /questions/{id}Update a question. Same body as POST.
DELETE /questions/{id}Soft-delete a question.
POST /questions/bulk-importImport a list of questions from a questions.json file.
// Accepts bare list or envelope
{"quizbuilder_version": "1.0", "questions": [...]}
Response: {"created": 12, "question_ids": ["...", ...]}
All endpoints require admin or manager role.
GET /testsList all tests (excludes deleted).
GET /tests/{id}Get test details including all blocks and embedded question data.
POST /testsCreate a test.
{
"title": "Mid-term Exam",
"mode": "async",
"access": "open",
"time_limit_minutes": 60,
"show_score": "at_end",
"show_correct_answers": "never",
"draw_count": null,
"blocks": [
{
"title": "Section 1",
"order": 0,
"context_json": null,
"questions": [
{"question_id": "<uuid>", "order": 0}
]
}
]
}
PUT /tests/{id}Update a test. Replaces all blocks atomically.
POST /tests/{id}/publishGenerate a link_token and set published_at. The test becomes accessible to takers. Requires at least one question.
DELETE /tests/{id}Soft-delete a test. Requires admin role.
POST /tests/{id}/cloneDuplicate a test and all its blocks as a new unpublished test. Questions are cloned as new independent copies, so editing the clone never affects the original.
GET /tests/{id}/access-codesList all access codes for a test (newest first). Each item: {id, code, max_uses, use_count, remaining, revoked, created_at} (remaining is null when max_uses is null).
POST /tests/{id}/access-codesGenerate one or more access codes for a code-gated test (e.g. one per student).
{
"count": 30, // 1–500 (default 1)
"max_uses": 1, // uses allowed per code; null = unlimited
"prefix": "CLASS-" // optional; sanitised to [A-Z0-9-_], max 16 chars
}
Codes use an unambiguous alphabet (no 0/O, 1/I/L). Returns the list of created codes (201).
DELETE /tests/{id}/access-codes/{code_id}Revoke a code. Soft — the row is kept (sets revoked = true) so session history referencing it stays intact.
GET /tests/{id}/previewReturn full test content with question details for admin preview — no session created.
GET /tests/{id}/exportExport a test.
quizbuilder_<slug>.jsonquizbuilder_<slug>.zip containing test.json + assets/POST /tests/importImport a test from a QuizBuilder JSON export (request body). Accepts both the Simple and
Internal content formats, with or without the {test:{…}} envelope, and a flat top-level
questions[] list (wrapped into one block). See
question-types.md › Two authoring formats.
POST /tests/import-bundleImport a test from a ZIP bundle (multipart/form-data, field file). Re-uploads all media assets and remaps IDs.
POST /tests/import-csvCreate a test from a CSV file (multipart/form-data, field file). The filename (without extension) becomes the test title. Expected header row: type, prompt, options, correct_answer, points, tags, block.
options — pipe-separated for choice types, e.g. Paris|London|Berlincorrect_answer — single value for MC/TF, comma-separated for MS (A,C), plain text for short_textblock — optional block title to group rows (all rows share one block if omitted)GET /tests/{id}/export-questionsExport just the questions as a portable questions.json file.
GET /tests/{id}/practice-bundlePublic endpoint — returns the test as a practice-bundle JSON for the mobile app. Only available when practice_enabled is true on the test (404 otherwise).
POST /sessions/take/{link_token}Start a new session. Returns all questions for the session.
Access control (enforced by the test’s access setting):
open — public; taker_email optional.registered — requires a valid Bearer token; the session is bound to that user. Returns 401 without one.code — requires access_code in the body matching a non-revoked code with uses remaining. Returns 403 otherwise; a successful start consumes one use.Attempt limits: when allow_multiple_attempts is false (or max_attempts is reached) a repeat start returns 403. Attempts are counted per logged-in user, per access code, or per taker_email — fully anonymous open takers (no email) are not limited.
// Request
{
"taker_email": "student@example.com", // optional
"access_code": "ABC123" // required only when access == "code"
}
// Response
{
"id": "<session_uuid>",
"test_id": "...",
"status": "active",
"expires_at": "2026-04-08T15:00:00Z",
"show_correct_answers": "at_end",
"questions": [
{
"id": "...", "type": "multiple_choice",
"prompt_json": {...}, "options_json": [...],
"points": 1, "order": 0,
"block_title": "Section 1",
"context_json": null
}
]
}
PUT /sessions/{id}/answers/{question_id}Save or update a taker’s answer (auto-save). Returns 204 No Content.
{"value": {"selected": "b"}}
POST /sessions/{id}/check/{question_id}Get immediate feedback for one answer. Only available when show_correct_answers == "per_question".
// Request
{"value": {"selected": "b"}}
// Response
{
"is_correct": true,
"needs_review": false,
"auto_score": 1,
"correct_answer": "b",
"options_json": [...]
}
POST /sessions/{id}/submitSubmit the session. Runs scoring. Returns the full result.
GET /sessions/{id}/resultRe-fetch the result of a submitted session.
Requires admin or manager role.
GET /resultsList sessions across all tests, with filtering.
Query params: test_id, status, review_status
GET /results/export/csvDownload submitted sessions for one test as CSV. Query param: test_id (required). Columns: session_id, taker_email, status, review_status, score_pct, passed, started_at, submitted_at.
GET /results/{session_id}Get a single session result with all answers.
POST /mediaUpload a file. Requires admin or manager role. multipart/form-data, field file.
Allowed types: image/jpeg, image/png, image/gif, image/webp, image/svg+xml, audio/mpeg, audio/ogg, audio/wav, audio/webm, video/mp4, video/webm, video/ogg
Max size: 50 MB
// Response
{
"id": "<uuid>",
"filename": "word.mp3",
"mime_type": "audio/mpeg",
"size_bytes": 24601,
"url": "/api/v1/media/<uuid>"
}
GET /media/{id}Serve a media file. Public endpoint (no auth). Supports HTTP range requests for audio/video streaming.
Requires admin role.
GET /users — list usersPOST /users — create userDELETE /users/{id} — deactivate user (sets is_active = false)Real-time competitive games are served under /api/v1/live/. See live-game.md for the full endpoint set, state machine, and scoring formula.
GET /healthReturns {"status": "ok"}. Used by Docker Compose health checks.