AdaptiveTest v0.2 · All endpoints with sample calls
All endpoints are served from the Next.js app. Configure via .env.local:
# ── Authentication ──────────────────────────────────────────────── GOOGLE_CLIENT_ID=310180611458-xxxx.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-xxxx NEXTAUTH_SECRET=<random-32-byte-base64> NEXTAUTH_URL=http://localhost:3002 # change for production # First user to sign in gets admin role; or set explicitly: ADMIN_EMAIL=admin@yourschool.com # ── Data provider ────────────────────────────────────────────────── DATA_PROVIDER=local # "local" | "api" # Only needed when DATA_PROVIDER=api: # EXTERNAL_API_BASE_URL=https://your-api.example.com/v1 # EXTERNAL_API_KEY=your-secret-key # EXTERNAL_API_TIMEOUT_MS=10000
Internal API routes use session cookies (set automatically after Google OAuth login). The optional external data API uses an X-API-Key header — see Section 8.
http://localhost:3002 — Replace with your production domain before deploying.Every student-facing action maps to an internal API route. When DATA_PROVIDER=api those routes transparently proxy to your external service — your frontend never changes.
Important: The entire grade question bank is loaded in one call when the student opens the test page. The adaptive engine then selects and sequences questions client-side. No per-question API calls are made during the test.
| Event | Internal API called | External (DATA_PROVIDER=api) |
|---|---|---|
| Student opens /test/5 (full bank, 1 call) | GET /api/questions?grade=5 | GET /questions?grade=5 |
| Student submits test | POST /api/sessions | PUT /sessions/{id} |
| Webhook fires (if configured) | — (server fires automatically) | POST <WEBHOOK_URL> |
| Student / admin views report | GET /api/sessions/{id} | GET /sessions/{id} |
| Download PDF report | GET /api/report/{id}/pdf | GET /sessions/{id} (to read session data) |
Webhook payload (auto-fired by POST /api/sessions)
When the student submits a test and a webhookUrl is provided in the POST body, the server immediately fires a POST to that URL with the full session payload. This is how your LMS receives live test results without polling.
// POST <webhookUrl> (fired server-side after session is saved)
// Headers:
// Content-Type: application/json
// X-Webhook-Secret: <WEBHOOK_SECRET env var, if set>
{
"event": "test.completed",
"sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
"grade": 5,
"timestamp": "2025-05-03T14:32:08.421Z",
"startedAt": "2025-05-03T14:12:00.000Z",
"completedAt": "2025-05-03T14:32:08.421Z",
"reportUrl": "https://app.example.com/report/a3f82c1d-...",
"annotations": { "studentId": "S-1042" },
"result": {
"score": 620,
"correctAnswers": 15,
"totalQuestions": 20,
"accuracy": 75,
"timeTaken": 1143,
"topicBreakdown": [
{ "topic": "Fractions & Decimals", "correct": 7, "total": 8, "percentage": 88 }
],
"difficultyBreakdown": [
{ "difficulty": 1, "correct": 4, "total": 4 },
{ "difficulty": 2, "correct": 5, "total": 6 },
{ "difficulty": 3, "correct": 3, "total": 5 },
{ "difficulty": 4, "correct": 3, "total": 5 }
],
"answers": [
{ "questionId": "q_g5_001", "selected": "C", "correct": "C", "isCorrect": true, "timeTaken": 48 }
]
}
}GET /api/sessions/{id}?payload=1 so you can inspect or replay it without re-running a test.The entire grade question bank is fetched in a single API call the moment a student opens the test page — not one question at a time. For Grade 5 this is typically ~120 questions returned in one response.
After the full bank arrives, the adaptive engine (initTestState()) selects 10–40 questions client-side based on the chosen duration and difficulty distribution. No further API calls are made per question during the test.
1× — GET /api/questions?grade=N when student opens the test page (full bank)1× — POST /api/sessions when student submits (saves results)DATA_PROVIDER=api, the first call proxies to your external GET /questions?grade=N.Returns the full question bank for a grade in one response. The adaptive engine then selects and sequences 10–40 questions client-side. No auth required — the /test/* pages are protected at the middleware level.
DATA_PROVIDER=api this proxies to your external GET /questions?grade=5. Your service must return the full bank — the app never requests individual questions.Request
const res = await fetch('http://localhost:3002/api/questions?grade=5');
const questions = await res.json(); // full Question[] for the grade (e.g. ~120 items)
// The adaptive engine then selects 10–40 of these client-side.
// You never need to call this per-question during the test.
console.log(`Loaded ${questions.length} questions for Grade 5`);Response
// 200 OK — full Question[] for the grade (~120 items for Grade 5)
// Truncated sample showing 3 questions across different difficulties:
[
{
"id": "q_g5_001",
"grade": 5,
"topic": "Fractions & Decimals",
"subtopic": "Adding Fractions",
"difficulty": 1,
"source": "Common Core",
"type": "multiple-choice",
"question": "What is 1/2 + 1/4?",
"options": ["1/2", "3/4", "2/6", "1"],
"answer": "B",
"explanation": "Convert to a common denominator: 2/4 + 1/4 = 3/4.",
"image": ""
},
{
"id": "q_g5_047",
"grade": 5,
"topic": "Geometry",
"subtopic": "Area of Composite Shapes",
"difficulty": 3,
"source": "Math Kangaroo",
"type": "multiple-choice",
"question": "A rectangle is 8 cm long and 5 cm wide. A square of side 2 cm is cut from one corner. What is the remaining area?",
"options": ["36 cm²", "38 cm²", "40 cm²", "34 cm²"],
"answer": "A",
"explanation": "Rectangle area = 40 cm². Square area = 4 cm². Remaining = 40 − 4 = 36 cm².",
"image": ""
},
{
"id": "q_g5_103",
"grade": 5,
"topic": "Number Theory",
"subtopic": "Prime Factorization",
"difficulty": 5,
"source": "AMC 8",
"type": "multiple-choice",
"question": "How many distinct prime factors does 360 have?",
"options": ["2", "3", "4", "5"],
"answer": "B",
"explanation": "360 = 2³ × 3² × 5. Three distinct prime factors: 2, 3, and 5.",
"image": ""
}
// ... ~117 more questions
]
// 400 — grade out of range
{ "error": "Invalid grade — must be 1–10" }Created automatically when a student finishes the adaptive test. The test page calls POST /api/sessions, receives a sessionId, and redirects to the report page. When DATA_PROVIDER=api, the saved session is also forwarded to your external service via PUT /sessions/{id} (see Section 9 — External Provider API). If a webhook URL was passed, it fires immediately after saving.
Create a new test session. Optionally fires a webhook to an external URL after saving.
Request
const res = await fetch('http://localhost:3002/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grade: 5,
startedAt: '2025-05-03T14:12:00.000Z',
baseUrl: 'http://localhost:3002',
webhookUrl: 'https://your-server.com/webhook', // optional
annotations: { studentId: 'S-1042' }, // optional
result: {
score: 620,
correctAnswers: 15,
totalQuestions: 20,
timeTaken: 1143,
topicBreakdown: [
{ topic: 'Fractions', correct: 7, total: 8, percentage: 88 }
],
difficultyBreakdown: [
{ difficulty: 1, correct: 4, total: 4 },
{ difficulty: 2, correct: 5, total: 6 }
],
answers: [
{ questionId: 'q_g5_001', selected: 'C', correct: 'C', isCorrect: true }
]
}
})
});
const data = await res.json();
// data.sessionId → use to build report URL
// data.reportUrl → redirect student hereResponse
// 200 OK
{
"sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
"reportUrl": "http://localhost:3002/report/a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b"
}
// 500 — body was invalid JSON
{ "error": "Failed to create session" }List all sessions (summary only — no per-question detail). Requires admin login.
Request
const res = await fetch('http://localhost:3002/api/sessions', {
credentials: 'include' // sends the session cookie
});
const sessions = await res.json();Response
// 200 OK — array of session summaries
[
{
"sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
"grade": 5,
"timestamp": "2025-05-03T14:32:08.421Z",
"score": 620,
"correctAnswers": 15,
"totalQuestions": 20,
"reportUrl": "http://localhost:3002/report/a3f82c1d-...",
"webhookSent": true
}
]
// 401 — not logged in as admin
{ "error": "Unauthorized" }Get a single session. Add ?payload=1 to get the webhook-shaped payload instead of raw session data.
Request
const id = 'a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b';
// Raw session
const raw = await fetch(`http://localhost:3002/api/sessions/${id}`).then(r => r.json());
// Webhook-shaped payload (same format POSTed to your webhook URL)
const payload = await fetch(`http://localhost:3002/api/sessions/${id}?payload=1`).then(r => r.json());Response
// 200 OK — raw StoredSession
{
"sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
"grade": 5,
"timestamp": "2025-05-03T14:32:08.421Z",
"startedAt": "2025-05-03T14:12:00.000Z",
"completedAt": "2025-05-03T14:32:08.421Z",
"webhookSent": true,
"reportUrl": "http://localhost:3002/report/a3f82c1d-...",
"annotations": { "studentId": "S-1042" },
"result": { "score": 620, "correctAnswers": 15, ... }
}
// 404
{ "error": "Session not found" }The report page at /report/{id} calls GET /api/sessions/{id} to load session data. The PDF route also reads the session — both proxy to GET /sessions/{id} on your external service when DATA_PROVIDER=api.
Download a fully-formatted A4 PDF report for a session. The session data is fetched via the active provider (local file or external GET /sessions/{id}). Safe to share directly with students — no login required.
Request
const id = 'a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b';
const res = await fetch(`http://localhost:3002/api/report/${id}/pdf`);
const blob = await res.blob();
// In browser — trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `math-report-${id.slice(0,8)}.pdf`;
a.click();Response
// 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="math-test-report-grade5-a3f82c1d.pdf"
Content-Length: <bytes>
<binary PDF data>
// 404
{ "error": "Session not found" }Used by teachers (reviewers) and managers to review and edit questions. All endpoints require the user to be logged in AND assigned to the requested grade.
Fetch all questions for a grade together with their current review status.
Request
const res = await fetch('http://localhost:3002/api/review/questions?grade=5', {
credentials: 'include'
});
const { questions, reviews } = await res.json();
// questions → Question[]
// reviews → Record<questionId, QuestionReview>Response
// 200 OK
{
"questions": [
{
"id": "q_g5_001",
"grade": 5,
"topic": "Fractions & Decimals",
"subtopic": "Adding Fractions",
"difficulty": 2,
"source": "Common Core",
"type": "multiple-choice",
"question": "What is 3/4 + 1/2?",
"options": ["1", "5/4", "1 1/4", "7/4"],
"answer": "C",
"explanation": "Convert 1/2 to 2/4: 3/4 + 2/4 = 5/4 = 1 1/4."
}
],
"reviews": {
"q_g5_001": {
"status": "approved",
"reviewedBy": "teacher@school.com",
"reviewedByName": "Jane Smith",
"reviewedAt": "2025-05-03T10:00:00Z",
"note": "Correct and clear."
},
"q_g5_002": { "status": "pending" }
}
}
// 403 — not assigned to this grade
{ "error": "Not assigned to this grade" }Submit or update a review decision (approve / flag / reset to pending) for one question.
Request
const res = await fetch('http://localhost:3002/api/review/submit', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
questionId: 'q_g5_001',
grade: 5,
status: 'approved', // "approved" | "flagged" | "pending"
note: 'Question is clear and answer is correct.'
})
});
const data = await res.json(); // { ok: true }Response
// 200 OK
{ "ok": true }
// 403
{ "error": "Not assigned to this grade" }Get the full edit history for a grade — who edited what, when, and what changed.
Request
const res = await fetch('http://localhost:3002/api/review/edit?grade=5', {
credentials: 'include'
});
const { editLog } = await res.json();Response
// 200 OK
{
"editLog": [
{
"id": "edit_7f3a2b",
"questionId": "q_g5_001",
"grade": 5,
"editedBy": "teacher@school.com",
"editedByName": "Jane Smith",
"editedAt": "2025-05-03T11:30:00Z",
"changes": [
{ "field": "Question", "before": "What is 3/4 + 1/4?", "after": "What is 3/4 + 1/2?" },
{ "field": "Answer", "before": "A", "after": "C" }
],
"comment": "Fixed the fraction in the question — was 1/4 should be 1/2."
}
]
}Save edits to a question. The API automatically diffs the changes and logs them.
Request
const res = await fetch('http://localhost:3002/api/review/edit', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
questionId: 'q_g5_001',
grade: 5,
comment: 'Fixed typo in option C',
updates: {
// Only include fields you want to change
question: 'What is 3/4 + 1/2?',
answer: 'C',
explanation: 'Convert 1/2 to 2/4: 3/4 + 2/4 = 5/4 = 1 1/4.',
options: ['1', '5/4', '1 1/4', '7/4']
// image, explanation can also be updated
}
})
});
const data = await res.json(); // { ok: true, editId: "edit_7f3a2b" }Response
// 200 OK
{ "ok": true, "editId": "edit_7f3a2b" }
// 404 — question not found in that grade
{ "error": "Question not found" }
// 403
{ "error": "Not assigned to this grade" }Per-grade review progress. Managers see only their assigned grades; admins see all 10.
Request
const res = await fetch('http://localhost:3002/api/manager/stats', {
credentials: 'include'
});
const { perGrade, totalQuestions, totalApproved,
totalFlagged, totalPending, allowedGrades } = await res.json();
// allowedGrades: number[] for managers, null for admin (= all grades)Response
// 200 OK
{
"allowedGrades": [4, 5, 6],
"totalQuestions": 360,
"totalApproved": 298,
"totalFlagged": 22,
"totalPending": 40,
"perGrade": [
{
"grade": 5,
"total": 120,
"approved": 98,
"flagged": 8,
"pending": 14,
"reviewers": [
{
"email": "teacher@school.com",
"name": "Jane Smith",
"reviewedCount": 106,
"approvedCount": 98,
"flaggedCount": 8
}
],
"assignedReviewers": [
{ "email": "teacher@school.com", "name": "Jane Smith" }
]
}
]
}List grade-level approval decisions. Managers see only their own decisions on assigned grades.
Request
const res = await fetch('http://localhost:3002/api/manager/approvals', {
credentials: 'include'
});
const { approvals } = await res.json();Response
// 200 OK
{
"approvals": [
{
"grade": 5,
"managerEmail": "manager@school.com",
"managerName": "John Manager",
"status": "approved",
"comment": "All 120 questions reviewed and signed off.",
"decidedAt": "2025-05-03T15:00:00Z"
},
{
"grade": 6,
"managerEmail": "manager@school.com",
"managerName": "John Manager",
"status": "revision_requested",
"comment": "15 questions still flagged — needs another pass.",
"decidedAt": "2025-05-03T16:00:00Z"
}
]
}Submit a grade-level approval or revision request. Manager must be assigned to the grade.
Request
const res = await fetch('http://localhost:3002/api/manager/approvals', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grade: 5,
status: 'approved', // or "revision_requested"
comment: 'All 120 questions reviewed.' // optional
})
});
const data = await res.json(); // { ok: true }Response
// 200 OK
{ "ok": true }
// 400 — invalid status value
{ "error": "Invalid status" }
// 403 — not assigned to that grade
{ "error": "Not assigned to this grade" }Get all questions for a grade with any overrides merged in.
Request
const res = await fetch('http://localhost:3002/api/admin/questions?grade=5', {
credentials: 'include'
});
const questions = await res.json(); // Question[]Response
// 200 OK — array of Question objects
[
{
"id": "q_g5_001",
"grade": 5,
"topic": "Fractions & Decimals",
"subtopic": "Adding Fractions",
"difficulty": 2,
"source": "Common Core",
"type": "multiple-choice",
"question": "What is 3/4 + 1/2?",
"options": ["1", "5/4", "1 1/4", "7/4"],
"answer": "C",
"explanation": "Convert 1/2 to 2/4: 3/4 + 2/4 = 5/4 = 1 1/4.",
"image": ""
}
]
// 400 — grade out of 1–10 range
{ "error": "Invalid grade" }Download an Excel workbook with all questions for a grade. Use ?grade=all for every grade.
Request
// Download for grade 5
const res = await fetch('http://localhost:3002/api/admin/export?grade=5', {
credentials: 'include'
});
const blob = await res.blob();
// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mathiq-questions-grade5.xlsx';
a.click();
// All grades
await fetch('http://localhost:3002/api/admin/export?grade=all', { credentials: 'include' });Response
// 200 OK Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet Content-Disposition: attachment; filename="mathiq-questions-grade5.xlsx" <binary .xlsx file>
Bulk import questions from an Excel file. Rows with empty id are added; rows with an existing id overwrite that question.
Request
const file = document.querySelector('input[type=file]').files[0];
const form = new FormData();
form.append('file', file);
const res = await fetch('http://localhost:3002/api/admin/import', {
method: 'POST',
credentials: 'include',
body: form // do NOT set Content-Type — browser adds multipart boundary
});
const { added, updated, errors } = await res.json();Response
// 200 OK
{
"added": 5,
"updated": 2,
"errors": []
}
// Partial success (some rows failed validation)
{
"added": 3,
"updated": 1,
"errors": [
"Row 8: answer must be A, B, C, or D — got 'E'",
"Row 12: grade is required"
]
}List all registered users.
Request
const res = await fetch('http://localhost:3002/api/admin/users', {
credentials: 'include'
});
const users = await res.json(); // User[]Response
// 200 OK
[
{
"email": "vishal.m@moonpreneur.com",
"name": "Vishal Malhotra",
"image": "https://lh3.googleusercontent.com/...",
"role": "admin",
"createdAt": "2025-04-01T09:00:00Z",
"lastLogin": "2025-05-03T14:00:00Z"
},
{
"email": "teacher@school.com",
"name": "Jane Smith",
"image": "",
"role": "reviewer",
"createdAt": "2025-04-15T10:30:00Z",
"lastLogin": "2025-05-03T08:00:00Z"
}
]Change a user's role. The email address in the URL path must be URL-encoded.
Request
const email = 'teacher@school.com';
const res = await fetch(
`http://localhost:3002/api/admin/users/${encodeURIComponent(email)}`,
{
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'manager' })
// role: "student" | "reviewer" | "manager" | "admin"
}
);
const user = await res.json(); // updated User objectResponse
// 200 OK — updated user
{
"email": "teacher@school.com",
"name": "Jane Smith",
"role": "manager",
"createdAt": "2025-04-15T10:30:00Z"
}
// 404
{ "error": "User not found" }Permanently delete a user account.
Request
const email = 'teacher@school.com';
const res = await fetch(
`http://localhost:3002/api/admin/users/${encodeURIComponent(email)}`,
{ method: 'DELETE', credentials: 'include' }
);
const data = await res.json(); // { ok: true }Response
// 200 OK
{ "ok": true }
// 404
{ "error": "User not found" }Full review statistics across all 10 grades with per-reviewer breakdowns.
Request
const res = await fetch('http://localhost:3002/api/admin/review/stats', {
credentials: 'include'
});
const { perGrade, totalQuestions, totalApproved,
totalFlagged, totalPending } = await res.json();Response
// 200 OK
{
"totalQuestions": 1200,
"totalApproved": 980,
"totalFlagged": 72,
"totalPending": 148,
"perGrade": [
{
"grade": 1,
"total": 120, "approved": 108, "flagged": 4, "pending": 8,
"reviewers": [
{ "email": "t1@school.com", "name": "Alice",
"reviewedCount": 112, "approvedCount": 108, "flaggedCount": 4 }
]
}
]
}List all teacher reviewer assignments along with all user accounts (for the assignment UI).
Request
const res = await fetch('http://localhost:3002/api/admin/review/assignments', {
credentials: 'include'
});
const { assignments, users } = await res.json();Response
// 200 OK
{
"assignments": [
{
"email": "teacher@school.com",
"name": "Jane Smith",
"grades": [4, 5, 6],
"assignedBy": "admin@school.com",
"assignedAt": "2025-04-20T09:00:00Z"
}
],
"users": [ /* User[] — all registered users */ ]
}Assign a teacher to review specific grades. Replaces any existing assignment for that email.
Request
const res = await fetch('http://localhost:3002/api/admin/review/assignments', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'teacher@school.com',
name: 'Jane Smith',
grades: [4, 5, 6]
})
});
const data = await res.json(); // { ok: true }Response
// 200 OK
{ "ok": true }Remove all grade assignments from a teacher reviewer.
Request
const res = await fetch(
`http://localhost:3002/api/admin/review/assignments/${encodeURIComponent('teacher@school.com')}`,
{ method: 'DELETE', credentials: 'include' }
);
const data = await res.json(); // { ok: true }Response
// 200 OK
{ "ok": true }Assign a manager to oversee specific grades. Same pattern as reviewer assignments.
Request
const res = await fetch('http://localhost:3002/api/admin/review/managers', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'manager@school.com',
name: 'John Manager',
grades: [4, 5, 6, 7, 8]
})
});Response
// 200 OK
{ "ok": true }Get the complete edit log for a grade (admin view — no grade-assignment check).
Request
const res = await fetch('http://localhost:3002/api/admin/review/log?grade=5', {
credentials: 'include'
});
const { editLog } = await res.json();Response
// 200 OK — same shape as GET /api/review/edit
{
"editLog": [
{
"id": "edit_7f3a2b",
"questionId": "q_g5_001",
"grade": 5,
"editedBy": "teacher@school.com",
"editedByName": "Jane Smith",
"editedAt": "2025-05-03T11:30:00Z",
"changes": [
{ "field": "Answer", "before": "A", "after": "C" }
],
"comment": "Fixed answer key"
}
]
}DATA_PROVIDER=api. These are the endpoints your external service must implement. The app calls them internally — your frontend never calls these directly.All requests from the app include these headers:
X-API-Key: <EXTERNAL_API_KEY> Content-Type: application/json
Questions
Return all questions for a grade (with overrides merged).
Request
// Called internally by ApiDataProvider — not from your frontend GET https://your-api.example.com/v1/questions?grade=5 X-API-Key: your-secret-key
Response
// 200 OK
{ "questions": [ /* Question[] */ ] }Upsert a question override. If the question id exists, replace it; otherwise add it.
Request
PUT https://your-api.example.com/v1/questions/override
X-API-Key: your-secret-key
Content-Type: application/json
{
"id": "q_g5_001",
"grade": 5,
"topic": "Fractions",
"answer": "C",
...full Question object...
}Response
// 200 OK
{ "ok": true }Bulk upsert questions. Empty-id rows get new IDs; existing-id rows are overwritten.
Request
POST https://your-api.example.com/v1/questions/import
X-API-Key: your-secret-key
{ "rows": [ /* Partial<Question>[] from the uploaded spreadsheet */ ] }Response
// 200 OK
{ "added": 3, "updated": 1, "errors": [] }Users
Create a user. Called on first login when the user doesn't exist yet.
Request
POST https://your-api.example.com/v1/users
X-API-Key: your-secret-key
{
"email": "newuser@school.com",
"name": "New User",
"image": "https://lh3.googleusercontent.com/...",
"role": "student"
}Response
// 201 Created
{
"email": "newuser@school.com",
"name": "New User",
"image": "",
"role": "student",
"createdAt": "2025-05-03T14:00:00Z"
}Update a user's role or record their last login timestamp.
Request
// Update role
PATCH https://your-api.example.com/v1/users/teacher%40school.com
X-API-Key: your-secret-key
{ "role": "manager" }
// Or record last login
{ "lastLoginAt": "2025-05-03T14:00:00Z" }Response
// 200 OK — updated User object
Sessions
Create or replace a session. Called every time a test session is saved or updated.
Request
PUT https://your-api.example.com/v1/sessions/a3f82c1d-...
X-API-Key: your-secret-key
{
"sessionId": "a3f82c1d-9e4b-4f7a-b0c5-2d1e8f9a3c7b",
"grade": 5,
"timestamp": "2025-05-03T14:32:08.421Z",
"startedAt": "2025-05-03T14:12:00.000Z",
"completedAt": "2025-05-03T14:32:08.421Z",
"webhookSent": false,
"reportUrl": "https://app.example.com/report/a3f82c1d-...",
"result": { ... }
}Response
// 200 OK
{ "ok": true }Reviews, Edit Log, Assignments, Approvals
| Method | Endpoint | Description |
|---|---|---|
| GET | /reviews | All reviews as Record<questionId, QuestionReview> |
| PUT | /reviews/{questionId} | Upsert a review for one question |
| GET | /edit-log?grade=N | Edit log entries (optional grade filter) |
| POST | /edit-log | Append a new edit log entry |
| GET | /assignments | List all reviewer grade assignments |
| GET | /assignments/{email} | Get one reviewer's assignment |
| PUT | /assignments/{email} | Upsert a reviewer assignment |
| DELETE | /assignments/{email} | Remove a reviewer assignment |
| GET | /manager-assignments | List all manager assignments |
| PUT | /manager-assignments/{email} | Upsert a manager assignment |
| DELETE | /manager-assignments/{email} | Remove a manager assignment |
| GET | /grade-approvals | List all grade approval records |
| PUT | /grade-approvals/{grade}/{managerEmail} | Upsert a grade approval decision |
Error Envelope (all external endpoints)
// All errors must return this shape with an appropriate HTTP status code:
{ "error": "Human-readable description of what went wrong" }
// Common status codes:
// 400 — invalid input / missing required field
// 404 — resource not found
// 500 — internal server error