PedsHub API Reference¶
Base URL: /api
All endpoints return JSON unless noted otherwise. Authentication is via Bearer token in the Authorization header. Role hierarchy: admin > moderator > user.
Auth (/api/auth)¶
POST /api/auth/register¶
Register a new user account.
- Auth: None
- Rate limit: Cap verification required if configured
- Request body:
- Response:
- First user ever:
{"access_token": "...", "token_type": "bearer"}(auto-admin, auto-verified) - Subsequent users:
{"requires_verification": true, "message": "..."} - Notes: Registration can be disabled by admin via settings. First registered user is always admin and auto-verified.
POST /api/auth/login¶
Closed. Answers 410 Gone with a message pointing to One Sign In. Sign-in is
GET /api/auth/sso/login → provider → GET /api/auth/sso/callback →
POST /api/auth/sso/exchange.
GET /api/auth/verify-email¶
Verify a user's email address via token link.
- Auth: None
- Query params:
token(string, required) - Response:
{"message": "Email verified successfully! You can now log in."} - Errors: 400 invalid/expired token
POST /api/auth/resend-verification¶
Resend the email verification link.
- Auth: None
- Request body:
{"email": "string"} - Response:
{"message": "If that email exists, a verification link has been sent."} - Notes: Deliberately vague response to prevent email enumeration.
POST /api/auth/forgot-password¶
Request a password reset link.
- Auth: None
- Rate limit: 3 reset requests per email per hour
- Request body:
{"email": "string"} - Response:
{"message": "If that email is registered, a reset link has been sent."} - Notes: Deliberately vague response to prevent email enumeration. Sends email in background.
POST /api/auth/reset-password¶
Gone (404). Passwords are not set or reset here; accounts and credentials live at One Sign In.
GET /api/auth/me¶
Get the current authenticated user's profile.
- Auth: User
- Response:
UserResponse—{id, email, name, role, created_at}
PUT /api/auth/me¶
Update current user's profile (name, password).
- Auth: User
- Request body:
- Response:
UserResponse
GET /api/auth/me/settings¶
Get user settings stored in Redis (e.g. Nextcloud config).
- Auth: User
- Response:
{}or a JSON object of user settings
PUT /api/auth/me/settings¶
Save user settings to Redis.
- Auth: User
- Request body: Any JSON object
- Response:
{"saved": true}
Documents (/api/documents)¶
POST /api/documents/upload¶
Upload a PDF document for processing.
- Auth: Moderator
- Content-Type:
multipart/form-data - Request:
file(PDF, max size fromMAX_UPLOAD_SIZEsetting) - Response:
DocumentResponse—{id, user_id, filename, original_filename, status, total_pages, error_message, uploaded_at} - Notes: Processing dispatched to Celery (falls back to sync). Status starts as
"processing".
GET /api/documents/¶
List all documents. Moderators see all; regular users see only their own.
- Auth: User
- Response:
DocumentResponse[]
GET /api/documents/{document_id}¶
Get a single document by ID.
- Auth: User (own documents only; moderators can access any)
- Response:
DocumentResponse - Errors: 404
GET /api/documents/{document_id}/status¶
Poll document processing status.
- Auth: User (own documents only; moderators can access any)
- Response:
{id, status, total_pages, error_message}
GET /api/documents/{document_id}/processing-steps¶
Get PDF processing progress steps from Redis.
- Auth: User (own documents only; moderators can access any)
- Response:
{"steps": [{step object}, ...]} - Notes: Steps are stored in Redis list
pdf:steps:{document_id}.
POST /api/documents/{document_id}/sections¶
Create a named page range section within a document.
- Auth: Moderator
- Request body:
- Response:
SectionResponse—{id, document_id, name, start_page, end_page} - Errors: 400 if document not ready, invalid page range
DELETE /api/documents/{document_id}/sections/{section_id}¶
Delete a section.
- Auth: Moderator
- Response: 204 No Content
DELETE /api/documents/{document_id}¶
Delete a document, its file, and its ChromaDB vector collection.
- Auth: Moderator
- Response: 204 No Content
Quizzes (/api/quizzes)¶
POST /api/quizzes/¶
Start asynchronous draft-only extraction from a document section.
- Auth: Moderator
- Request body:
- Response:
{"job_id": "uuid", "status": "pending"} - Notes: Poll
/api/quizzes/job/{job_id}for progress. A successful job creates a reviewDraftBatch, not live Questions or a Quiz. If Celery/Redis cannot accept the job, this endpoint returns 503 and creates no bank content; it never falls back to synchronous extraction.
GET /api/quizzes/jobs¶
List all recent extraction jobs for the current user (any browser session).
- Auth: User
- Response:
GET /api/quizzes/job/{job_id}¶
Poll extraction job progress.
- Auth: Moderator
- Response:
{"job_id", "status", "steps": [...], "batch_id" (if completed), "error" (if failed)}
POST /api/quizzes/job/{job_id}/cancel¶
Cancel a running extraction job.
- Auth: User (must own the job)
- Response:
{"status": "cancelled"} - Errors: 403 if not owner, 400 if not running
GET /api/quizzes/search¶
Hybrid semantic + keyword search across quiz titles and questions.
- Auth: User
- Query params:
q(string, required, 2-200 chars)mode(string:"title"|"questions"|"all", default"all")- Response: Array of search results with
quiz_id,quiz_title,match_type,matching_questions[] - Notes: Uses pgvector cosine similarity (threshold 0.30) for semantic search, plus ILIKE for keyword matching.
GET /api/quizzes/¶
List all quizzes. Moderators see all (non-deleted); regular users see only published.
- Auth: User
- Response:
QuizResponse[]—{id, section_id, user_id, title, questions_count, mode, time_limit_minutes, is_published, created_at}
GET /api/quizzes/{quiz_id}¶
Get a quiz for taking. Learning-mode quizzes include answers/explanations.
- Auth: User
- Query params:
study(bool, default false) — forces answers/explanations to be included - Response:
QuizDetail(timed) orQuizLearningDetail(learning/study) - Notes: Excludes soft-deleted quizzes.
PATCH /api/quizzes/{quiz_id}¶
Update quiz metadata (title).
- Auth: Moderator
- Request body:
{"title": "string"} - Response:
QuizResponse
POST /api/quizzes/{quiz_id}/shuffle¶
Return quiz with shuffled question and option order (does not modify DB).
- Auth: User
- Query params:
shuffle_options(bool, default true) - Response:
QuizDetailwith shuffled questions
GET /api/quizzes/{quiz_id}/review¶
Get quiz with full answers. Requires a completed attempt (or moderator role).
- Auth: User (must have completed attempt) or Moderator
- Response:
QuizReview
GET /api/quizzes/{quiz_id}/questions¶
Get all questions with answers for editing.
- Auth: Moderator
- Response: Array of
{id, question_text, question_type, options, correct_answer, explanation}
PATCH /api/quizzes/{quiz_id}/questions/{question_id}¶
Update a question. Changes are shared across all quizzes referencing this question.
- Auth: Moderator
- Request body: Any of
{question_text, options, correct_answer, explanation, question_type} - Response: Updated question object
- Errors: 400 if correct_answer does not match one of the options
DELETE /api/quizzes/{quiz_id}/questions/{question_id}¶
Remove a question from this quiz. If shared with other quizzes, only the link is removed. If exclusive, the question is deleted from the bank.
- Auth: Moderator
- Response: 204 No Content
PATCH /api/quizzes/{quiz_id}/publish¶
Show or hide a quiz for regular users.
- Auth: Moderator
- Query params:
published(bool, default true) - Response:
{"quiz_id": int, "is_published": int}
GET /api/quizzes/trash¶
List soft-deleted quizzes.
- Auth: Moderator
- Response:
QuizResponse[]
DELETE /api/quizzes/{quiz_id}¶
Soft-delete a quiz (move to trash).
- Auth: Moderator
- Response: 204 No Content
PATCH /api/quizzes/{quiz_id}/restore¶
Restore a soft-deleted quiz from trash.
- Auth: Moderator
- Response:
QuizResponse
DELETE /api/quizzes/{quiz_id}/permanent¶
Permanently delete a quiz that is already in trash. Exclusive questions are orphaned to the bank.
- Auth: Moderator
- Response: 204 No Content
- Errors: 400 if quiz is not in trash
Questions (/api/questions)¶
GET /api/questions/bank¶
List all questions across all quizzes with filtering, search, and pagination.
- Auth: User
- Query params:
q(string, optional) — keyword/semantic searchquiz_id(int, optional) — filter by source quizcategory_id(int, optional) — filter by question categorycategory_ids(string, optional) — comma-separated category IDs (OR filter)uncategorized(bool, default false)favorites_only(bool, default false)tag_ids(string, optional) — comma-separated tag IDs (AND filter)search_mode(string:"keyword"|"semantic"|"hybrid", default"hybrid")limit(int, default 50, max 200)offset(int, default 0)- Response:
{ "total": "int", "questions": [ { "id": "int", "quiz_id": "int", "quiz_title": "string", "question_category_id": "int | null", "question_category_name": "string | null", "question_text": "string", "question_type": "string", "options": ["string"], "correct_answer": "string", "explanation": "string", "image_path": "string | null" } ] } - Notes: Hybrid search returns semantic matches first (pgvector cosine similarity >= 0.55), then keyword matches.
GET /api/questions/bank/ids¶
Return just question IDs for all matching questions (for server-side select-all).
- Auth: User
- Query params: Same filters as
/bank(exceptlimit,offset,search_mode) - Response:
[int]
PATCH /api/questions/{question_id}/category¶
Assign or remove a question category.
- Auth: Moderator
- Query params:
category_id(int | null) - Response:
{"question_id": int, "question_category_id": int | null}
POST /api/questions/from-bank¶
Create a new quiz by referencing existing bank questions (no copying -- edits propagate).
- Auth: Moderator
- Request body:
- Response:
{"id": int, "title": "string", "questions_count": int}
POST /api/questions/bulk-category¶
Assign or remove a category from multiple questions at once.
- Auth: Moderator
- Request body:
- Response:
{"updated": int, "question_category_id": int | null}
Attempts (/api/attempts)¶
POST /api/attempts/start¶
Start a new quiz attempt (or resume the most recent incomplete one).
- Auth: User
- Query params:
quiz_id(int, required)fresh(bool, default false) — force a new attempt even if one is in progress- Response:
AttemptResponse—{id, quiz_id, score, total_questions, percentage, started_at, completed_at}
POST /api/attempts/{attempt_id}/submit¶
Submit answers for a quiz attempt.
- Auth: User (must own the attempt)
- Request body:
- Response:
AttemptDetail— includesanswers[]with full review (question text, options, user answer, correct answer, is_correct, explanation) - Notes: Updates spaced repetition reminder schedule. Clears saved progress from Redis. Unanswered questions are marked incorrect.
GET /api/attempts/¶
List all attempts for the current user.
- Auth: User
- Query params:
quiz_id(int, optional) - Response:
AttemptResponse[]
GET /api/attempts/{attempt_id}¶
Get a specific attempt with full answer details.
- Auth: User (must own the attempt)
- Response:
AttemptDetail
GET /api/attempts/stats/dashboard¶
Get dashboard statistics for the current user.
- Auth: User
- Response:
GET /api/attempts/history¶
Get per-quiz attempt history for the performance line graph.
- Auth: User
- Response:
GET /api/attempts/in-progress¶
Return all incomplete (not yet submitted) attempts for the current user.
- Auth: User
- Response:
GET /api/attempts/{attempt_id}/in-progress¶
Return the latest incomplete attempt for a specific quiz.
- Auth: User
- Query params:
quiz_id(int, required) - Response:
AttemptResponse | null
POST /api/attempts/progress¶
Save in-progress quiz answers to Redis (survives logout/browser change).
- Auth: User
- Headers:
x-quiz-session(optional, for device session lock) - Request body:
- Response:
{"saved": true} - Notes: Progress expires after 7 days. Session lock (30s TTL) prevents concurrent resume on another device.
GET /api/attempts/progress¶
Retrieve saved progress for a quiz. Auto-submits timed quizzes if timer expired.
- Auth: User
- Headers:
x-quiz-session(optional, for device session lock) - Query params:
quiz_id(int, required) - Response: Saved progress object or
null - Errors: 409 if another device is actively using this attempt
DELETE /api/attempts/progress/{attempt_id}¶
Clear saved progress.
- Auth: User
- Response: 204 No Content
DELETE /api/attempts/{attempt_id}¶
Delete an attempt, its answers, and associated reminder schedule.
- Auth: User (must own the attempt)
- Response: 204 No Content
Tags (/api/tags)¶
GET /api/tags¶
Get all tags grouped by type (subjects, diseases, keywords), with question counts.
- Auth: User
- Response:
POST /api/tags/classify¶
Trigger background AI classification of untagged questions.
- Auth: Moderator
- Response:
{"job_id": "uuid", "status": "started"}
GET /api/tags/status/{job_id}¶
Check classification job progress.
- Auth: User
- Response:
{"job_id": "string", "status": "string", "steps": [...], "error": "string | null"}
Teach (/api/teach)¶
GET /api/teach/models¶
Return available teach AI models.
- Auth: User
- Response:
[{"id": int, "name": "string", "model_id": "string", "is_default": bool}]
POST /api/teach/chat¶
Send a message to the AI tutor with full question context.
- Auth: User
- Rate limit: 30 messages per user per 10 minutes (admins and unthrottled users exempt)
- Request body:
- Response:
{"reply": "string (markdown)", "suggestions": ["string", "string", "string"]} - Notes: System prompt includes the question, correct answer, explanation, and up to 4 semantically similar questions for broader context. Follow-up suggestions are parsed from lines starting with
>. - Errors: 503 if no teach model configured, 502 if AI temporarily unavailable
TTS (/api/tts)¶
GET /api/tts/voices¶
Return available TTS voices. Excludes Polly voices if Polly is disabled.
- Auth: User
- Response:
[{"id": "string (model_id)", "name": "string", "is_default": bool}]
POST /api/tts/speak¶
Convert text to speech audio.
- Auth: User
- Rate limit: 60 requests per user per hour (admins and unthrottled users exempt)
- Request body:
- Response: Binary
audio/mpegdata - Notes: Supports LiteLLM-routed local TTS voices, e.g.
local-kokoro-tts:am_adam. The prefix before:is the LiteLLM model route; the suffix is the speaker voice sent to that route.
Favorites (/api/favorites)¶
POST /api/favorites¶
Add a question to the current user's favorites.
- Auth: User
- Request body:
{"question_id": "int"} - Response (201):
FavoriteResponse—{id, user_id, question_id, created_at} - Notes: Idempotent -- returns existing favorite if already favorited.
DELETE /api/favorites/{question_id}¶
Remove a question from favorites.
- Auth: User
- Response: 204 No Content
GET /api/favorites¶
Get list of favorited question IDs for the current user.
- Auth: User
- Response:
[int]
Categories (/api/categories)¶
Quiz-level categories for organizing quizzes.
GET /api/categories/¶
List all quiz categories with quiz counts.
- Auth: User
- Response:
[{"id": int, "name": "string", "quiz_count": int}]
POST /api/categories/¶
Create a new quiz category.
- Auth: Moderator
- Request body:
{"name": "string"} - Response:
CategoryResponse
DELETE /api/categories/{category_id}¶
Delete a quiz category. Unassigns all quizzes in the category.
- Auth: Moderator
- Response: 204 No Content
PATCH /api/categories/quizzes/{quiz_id}¶
Assign or remove a category from a quiz.
- Auth: Moderator
- Query params:
category_id(int | null) - Response:
{"quiz_id": int, "category_id": int | null}
Question Categories (/api/question-categories)¶
Question-level categories for organizing the question bank.
GET /api/question-categories/¶
List all question categories with question counts.
- Auth: User
- Response:
[{"id": int, "name": "string", "description": "string | null", "question_count": int}]
POST /api/question-categories/¶
Create a new question category.
- Auth: Moderator
- Request body:
{"name": "string", "description": "string | null"} - Response:
QCatResponse
PATCH /api/question-categories/{cat_id}¶
Update a question category.
- Auth: Moderator
- Request body:
{"name": "string", "description": "string | null"} - Response:
QCatResponse
DELETE /api/question-categories/{cat_id}¶
Delete a question category. Optionally move its questions to another category.
- Auth: Moderator
- Query params:
move_to(int | null) -- target category ID, or null to uncategorize - Response: 204 No Content
POST /api/question-categories/{cat_id}/create-quiz¶
Create a new quiz from all questions in a question category.
- Auth: Moderator
- Query params:
title(string, required)mode(string:"timed"|"learning", default"timed")time_limit_minutes(int | null)- Response:
{"id": int, "title": "string", "questions_count": int}
Contact (/api/contact)¶
POST /api/contact¶
Submit a contact form (public, no auth).
- Auth: None
- Request body:
- Response:
{"success": true} - Notes: Stores submission in DB and emails admin. Cap verification if configured.
GET /api/contact/submissions¶
List all contact submissions.
- Auth: Admin (enforced in endpoint body, not decorator)
- Response:
[{id, name, email, type, message, read, created_at}]
PUT /api/contact/submissions/{submission_id}/read¶
Mark a contact submission as read.
- Auth: Admin (enforced in endpoint body, not decorator)
- Response:
{"success": true}
Admin (/api/admin)¶
User Management¶
GET /api/admin/users¶
List all users.
- Auth: Admin
- Response:
UserResponse[]
POST /api/admin/users¶
Create a user directly (email auto-verified).
- Auth: Admin
- Request body:
{"email": "string", "password": "string", "name": "string"} - Response:
UserResponse
PUT /api/admin/users/{user_id}/role¶
Change a user's role.
- Auth: Admin
- Request body:
{"role": "admin | moderator | user"} - Response:
UserResponse - Errors: 400 cannot change own role
PUT /api/admin/users/{user_id}/unthrottle¶
Set or clear the unthrottle flag (exempt from AI/TTS rate limits).
- Auth: Admin
- Request body:
{"unthrottled": true | false} - Response:
UserResponse
DELETE /api/admin/users/{user_id}¶
Delete a user and all their data.
- Auth: Admin
- Response: 204 No Content
- Errors: 400 cannot delete yourself
AI Model Configuration¶
GET /api/admin/models/available¶
List active models for a task (for users when creating/taking quizzes).
- Auth: User
- Query params:
task(string, default"extraction") - Response:
[{"id": int | null, "name": "string", "model_id": "string", "is_default": bool}] - Notes: Falls back to env
LITELLM_MODELif nothing configured.
GET /api/admin/models¶
List all AI model configurations.
- Auth: Admin
- Response:
AIModelConfigResponse[]--{id, name, model_id, task, is_active, is_default, api_key (masked)}
POST /api/admin/models¶
Create an AI model configuration.
- Auth: Admin
- Request body:
- Response:
AIModelConfigResponse - Notes: Auto-sets as default if first model for that task. Unique constraint on (model_id, task).
PUT /api/admin/models/{model_id}¶
Update an AI model configuration.
- Auth: Admin
- Request body: Partial
AIModelConfigUpdate - Response:
AIModelConfigResponse
DELETE /api/admin/models/{model_id}¶
Delete an AI model configuration.
- Auth: Admin
- Response: 204 No Content
POST /api/admin/models/{model_id}/test¶
Send a test completion to verify an LLM model is reachable.
- Auth: Admin
- Response:
{"message": "model_id -> 'OK'"} - Errors: 400 for TTS models (use Preview button instead), 502 if unreachable
POST /api/admin/litellm/models¶
Query available models from LiteLLM proxy or OpenAI-compatible API.
- Auth: Admin
- Request body:
{"api_key": "string | null", "api_base": "string | null"} - Response:
{"models": ["string"], "source": "string"}
TTS Voice Search¶
POST /api/admin/tts/voices¶
Discover available TTS voices from a provider.
- Auth: Admin
- Request body:
- Response:
{"voices": [{"model_id": "string", "name": "string", "labels": {}}]} - Notes: OpenAI returns hardcoded list of tts-1 and tts-1-hd voices.
System Settings¶
GET /api/admin/settings¶
Get system settings (embedding model, rerank model, site policy flags).
- Auth: Admin
- Response:
{"embedding_model": "string", "rerank_model": "string", ...flags}
PUT /api/admin/settings¶
Update system settings.
- Auth: Admin
- Request body:
{"embedding_model": "string", "rerank_model": "string", ...flags} - Response:
{"success": true, "message": "Settings updated"}
Embedding Management¶
POST /api/admin/embedding/test¶
Test the currently configured embedding model.
- Auth: Admin
- Response:
{"model": "string", "dimensions": int, "status": "ok"}
POST /api/admin/embedding/regenerate¶
Queue a Celery task to regenerate all question embeddings.
- Auth: Admin
- Response:
{"job_id": "uuid", "message": "Regeneration started..."}
Nextcloud (/api/nextcloud)¶
WebDAV proxy for importing PDFs from Nextcloud (avoids browser CORS issues).
POST /api/nextcloud/test¶
Test Nextcloud credentials.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/"} - Response:
{"ok": true, "message": "Connected successfully"}
POST /api/nextcloud/files¶
List PDFs and folders at a given Nextcloud path.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/"} - Response:
{"path": "string", "items": [{"name", "path", "type": "dir | pdf", "size"}]}
POST /api/nextcloud/download¶
Download a PDF from Nextcloud and stream it back.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/some/file.pdf"} - Response: Streamed
application/pdfbinary - Errors: 400 if not a PDF file