/api/v1/health
Public
Liveness check for uptime monitoring.
No auth, no rate limit.
Response 200 OK
{
"data": {
"status": "ok"
}
}
Backend REST API · 119 endpoints
Every route the APC CBT Practice backend exposes — request bodies, response shapes, and how to call them from a React/Next.js web app or a mobile app. Base URL: https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1.
{
"data": { … }
}
{
"message": "…",
"errors": { … }
}
Sanctum issues a plain bearer token here (not cookie sessions), so a Next.js app talks to this API exactly like any other token-based REST backend — no CSRF cookie dance required.
One small wrapper that attaches the token, unwraps data, and throws on the error envelope. Put this in lib/api.ts.
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1";
class ApiError extends Error {
constructor(message, public status, public errors) {
super(message);
}
}
export async function apiFetch(path, options = {}) {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Accept: "application/json",
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(body.message ?? "Request failed", res.status, body.errors);
}
return body.data;
}
import { apiFetch } from "@/lib/api";
async function login(email, password) {
const { token, user } = await apiFetch("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
localStorage.setItem("token", token);
return user;
}
import { apiFetch } from "@/lib/api";
async function startExamSetup(examTypeId, subjectIds, mode) {
return apiFetch("/exams/setup", {
method: "POST",
body: JSON.stringify({
exam_type_id: examTypeId,
subject_ids: subjectIds,
duration: 60,
mode,
}),
});
}
For a Server Component that needs the current user (e.g. reading a cookie-stored token instead of localStorage), pass the token explicitly and skip the browser-only localStorage check:
import { cookies } from "next/headers";
async function getProfile() {
const token = cookies().get("token")?.value;
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/profile`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
cache: "no-store",
});
return (await res.json()).data;
}
A token can expire (7-day Sanctum default) or be revoked. Catch a 401 from ApiError anywhere and redirect to login:
try {
const profile = await apiFetch("/profile");
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
}
Nothing above is React-specific — the apiFetch wrapper in step 1 is plain fetch() and drops into Vue, Svelte, a jQuery-era admin panel, or a no-framework static page unchanged. The only genuinely React-specific piece is step 4 (Server Components); everything else is just JavaScript.
Same bearer-token contract as the web client — the only real difference is where the token lives. Store it in the platform's secure storage, never in plain AsyncStorage/SharedPreferences.
Use expo-secure-store to persist the token, and the same fetch-wrapper pattern as the web client:
import * as SecureStore from "expo-secure-store";
const BASE_URL = "https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1";
async function apiFetch(path, options = {}) {
const token = await SecureStore.getItemAsync("token");
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.message), { status: res.status, errors: body.errors });
return body.data;
}
async function login(email, password) {
const { token, user } = await apiFetch("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
await SecureStore.setItemAsync("token", token);
return user;
}
Store the token in EncryptedSharedPreferences, attach it as a header via an OkHttp interceptor:
class AuthInterceptor(private val tokenStore: TokenStore) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenStore.get()
val request = chain.request().newBuilder()
.addHeader("Accept", "application/json")
.apply { if (token != null) addHeader("Authorization", "Bearer $token") }
.build()
return chain.proceed(request)
}
}
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenStore))
.build()
Store the token in the Keychain, attach it per-request:
var request = URLRequest(url: URL(string: "https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1/profile")!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = Keychain.shared.get("token") {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
Store the token with flutter_secure_storage (Keychain on iOS, Keystore-backed EncryptedSharedPreferences on Android), and wrap http the same way:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const baseUrl = 'https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1';
final _storage = const FlutterSecureStorage();
class ApiException implements Exception {
final String message;
final int status;
ApiException(this.message, this.status);
}
Future apiFetch(String path, {String method = 'GET', Object? body}) async {
final token = await _storage.read(key: 'token');
final res = await http.Request(method, Uri.parse('$baseUrl$path'))
..headers.addAll({
'Accept': 'application/json',
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
})
..body = body != null ? jsonEncode(body) : '';
final streamed = await res.send();
final response = await http.Response.fromStream(streamed);
final decoded = jsonDecode(response.body);
if (response.statusCode >= 400) {
throw ApiException(decoded['message'] ?? 'Request failed', response.statusCode);
}
return decoded['data'];
}
Future
File-upload endpoints expect multipart/form-data, not JSON. Do not set Content-Type manually — let the platform's multipart builder set the boundary:
const form = new FormData();
form.append("avatar", {
uri: photo.uri,
name: "avatar.jpg",
type: "image/jpeg",
});
await fetch(`${BASE_URL}/profile/avatar`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }, // no Content-Type here
body: form,
});
Flutter equivalent, via http.MultipartRequest:
final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/profile/avatar'))
..headers['Authorization'] = 'Bearer $token'
..files.add(await http.MultipartFile.fromPath('avatar', photo.path));
final response = await request.send();
Once GET /exams/{'{'}session{'}'}/start returns the question set, the exam screen can run entirely offline: hold answers locally, poll GET /exams/{'{'}session{'}'}/time when connectivity returns, and only call POST /exams/{'{'}session{'}'}/submit once — the endpoint rejects a second submit on an already-completed session, so a retried request after a dropped connection is safe to resend.
The desktop client is built to work with no connection at all: a local SQLite database is the source of truth for the UI, and the API is only ever talked to in the background — to pull down what changed, and to push up what the student did while offline.
Keep one local table per syncable resource (subjects, exam_types, questions, settings), plus a small sync_state table tracking the last successful cursor per resource:
-- main process, better-sqlite3 or node:sqlite CREATE TABLE IF NOT EXISTS sync_state ( resource TEXT PRIMARY KEY, synced_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00Z' ); CREATE TABLE IF NOT EXISTS subjects ( id INTEGER PRIMARY KEY, payload TEXT NOT NULL, -- the full JSON row from /sync/subjects updated_at TEXT NOT NULL ); -- same shape for exam_types, questions, settings CREATE TABLE IF NOT EXISTS outbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, endpoint TEXT NOT NULL, method TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL );
Run this on app launch and again whenever connectivity returns. Always store the response's own synced_at as the next cursor — never the newest row you received (see the note on GET /sync/{'{'}resource{'}'} in the reference below):
// main process — src/sync/pull.js
const RESOURCES = ['subjects', 'exam_types', 'questions', 'settings'];
async function pullAll(db, token) {
for (const resource of RESOURCES) {
const since = db.prepare('SELECT synced_at FROM sync_state WHERE resource = ?').get(resource)?.synced_at
?? '1970-01-01T00:00:00Z';
const res = await fetch(`https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1/sync/${resource}?since=${encodeURIComponent(since)}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
const { data, synced_at } = await res.json();
const upsert = db.prepare(`
INSERT INTO ${resource} (id, payload, updated_at) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at
`);
const tx = db.transaction((rows) => rows.forEach((row) => upsert.run(row.id, JSON.stringify(row), row.updated_at)));
tx(data);
db.prepare(`
INSERT INTO sync_state (resource, synced_at) VALUES (?, ?)
ON CONFLICT(resource) DO UPDATE SET synced_at = excluded.synced_at
`).run(resource, synced_at);
}
}
Renderer-side navigator.onLine reports the OS network interface state, not whether the API is actually reachable — pair it with a real health check against a route that always exists:
// renderer process
window.addEventListener('online', () => window.electronAPI.trySync());
// as a backstop, also poll every 30s in case the 'online' event
// never fires (common on flaky Wi-Fi rather than a real disconnect)
setInterval(() => window.electronAPI.trySync(), 30000);
// main process — src/sync/connectivity.js
async function isApiReachable() {
try {
const res = await fetch('https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1/health', { signal: AbortSignal.timeout(4000) });
return res.ok;
} catch {
return false;
}
}
async function trySync(db, token) {
if (!(await isApiReachable())) return;
await pullAll(db, token);
await flushOutbox(db, token);
}
A student can pause, answer questions, and even submit an exam while fully offline, since GET /exams/{'{'}session{'}'}/start already handed over the full question set. Queue the write locally and replay it once trySync() confirms the API is back:
function queueWrite(db, endpoint, method, body) {
db.prepare('INSERT INTO outbox (endpoint, method, body, created_at) VALUES (?, ?, ?, ?)')
.run(endpoint, method, JSON.stringify(body), new Date().toISOString());
}
async function flushOutbox(db, token) {
const pending = db.prepare('SELECT * FROM outbox ORDER BY id').all();
for (const job of pending) {
const res = await fetch(`https://exampracticecbt.rararaapcmegaapp.com.ng/api/v1${job.endpoint}`, {
method: job.method,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
body: job.body,
});
// 400 on an already-completed session (a retried submit) is expected and safe to
// treat as delivered — the first attempt clearly landed before the connection dropped.
if (res.ok || res.status === 400) {
db.prepare('DELETE FROM outbox WHERE id = ?').run(job.id);
} else {
break; // stop and retry the whole queue on the next trySync() — preserves write order
}
}
}
Keep SQLite, the token, and all networking in the main process; the renderer only ever calls a typed IPC bridge — never fetch() directly against the API from the UI thread:
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
trySync: () => ipcRenderer.invoke('sync:try'),
getSubjects: () => ipcRenderer.invoke('db:subjects'),
submitExam: (sessionId, answers) => ipcRenderer.invoke('exam:submit', sessionId, answers),
});
// main.js
ipcMain.handle('exam:submit', async (_event, sessionId, answers) => {
if (await isApiReachable()) {
return apiFetch(`/exams/${sessionId}/submit`, { method: 'POST', body: { answers } });
}
queueWrite(db, `/exams/${sessionId}/submit`, 'POST', { answers });
return { queued: true };
});
Infrastructure-level, not part of the product surface.
/api/v1/health
Public
Liveness check for uptime monitoring.
No auth, no rate limit.
{
"data": {
"status": "ok"
}
}
Shared by both students and admins — role is decided server-side, never by the client.
/api/v1/auth/register
Public
Create a student account.
5 req/min. A role field in the payload is silently ignored — every self-registration becomes a student.
{
"name": "Ada Obi",
"email": "ada@example.com",
"password": "secret123"
}
{
"data": {
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com",
"role": "student",
"phone": null,
"exam_type": null,
"avatar_url": null,
"is_pro": false,
"machine_id": null,
"created_at": "2026-07-20T10:00:00Z"
}
}
/api/v1/auth/login
Public
Exchange email + password for a Sanctum bearer token.
10 req/min. Wrong email and wrong password return the identical generic error — no user enumeration.
{
"email": "ada@example.com",
"password": "secret123"
}
{
"data": {
"token": "1|abcdEFGH...redacted",
"user": {
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com",
"role": "student"
}
}
}
/api/v1/auth/me
Auth required
Return the authenticated user's own profile.
{
"data": {
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com",
"role": "student",
"phone": null,
"exam_type": "JAMB",
"avatar_url": null,
"is_pro": false,
"machine_id": null,
"created_at": "2026-07-20T10:00:00Z"
}
}
/api/v1/auth/logout
Auth required
Revoke the current access token only.
Other devices/tokens for the same user stay signed in.
{
"data": {
"message": "Logged out successfully"
}
}
The authenticated user managing their own account. No endpoint here can touch role, email, or Pro status.
/api/v1/profile
Auth required
Fetch the current user's profile plus lifetime stats.
{
"data": {
"user": {
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com",
"role": "student",
"phone": "+2348012345678",
"exam_type": "JAMB",
"avatar_url": null,
"is_pro": false,
"machine_id": null,
"created_at": "2026-07-20T10:00:00Z"
},
"stats": {
"total_exams": 12,
"total_score": 340,
"total_possible": 480,
"average_percentage": 70.83,
"best_percentage": 92.5,
"total_time_spent": 21600,
"subject_stats": [
{
"subject_id": 3,
"subject_name": "Chemistry",
"correct": 88,
"total": 120,
"percentage": 73.33
}
],
"exam_type_stats": [
{
"exam_type_id": 1,
"exam_type": "JAMB",
"exams": 12,
"average_percentage": 70.83,
"best_percentage": 92.5,
"time_spent": 21600
}
]
}
}
}
/api/v1/profile
Auth required
Update name, phone, exam type, and machine_id.
email, role and is_pro are structurally excluded — sending them is silently ignored.
{
"name": "Ada N. Obi",
"phone": "+2348012345678",
"exam_type": "JAMB",
"machine_id": "DESKTOP-7F3K2Q"
}
{
"data": {
"id": 42,
"name": "Ada N. Obi",
"email": "ada@example.com",
"role": "student",
"phone": "+2348012345678",
"exam_type": "JAMB",
"avatar_url": null,
"is_pro": false,
"machine_id": "DESKTOP-7F3K2Q",
"created_at": "2026-07-20T10:00:00Z"
}
}
/api/v1/profile/avatar
Auth required
Upload and crop a new avatar image.
multipart/form-data, field `avatar` (png/jpg/jpeg/webp, max 2MB). Stored at 256×256; replaces any previous avatar.
{
"avatar": "(binary file)"
}
{
"data": {
"id": 42,
"name": "Ada N. Obi",
"avatar_url": "https://api.example.com/storage/avatars/42.jpg"
}
}
/api/v1/profile/change-password
Auth required
Change password given the current one.
The session/token stays valid after a successful change.
{
"current_password": "secret123",
"new_password": "evenBetter456"
}
{
"data": {
"message": "Password changed successfully"
}
}
/api/v1/profile/export
Auth required
Download a full export of the user's own data as a JSON file.
Profile, exam sessions with answers, bookmarks, and error reports — nothing belonging to anyone else.
{
"data": {
"profile": "{...UserResource}",
"exam_sessions": [
"{...session with nested answers}"
],
"bookmarks": [
{
"question_id": 501,
"created_at": "2026-07-18T09:00:00Z"
}
],
"error_reports": [],
"exported_at": "2026-07-20T12:00:00Z"
}
}
/api/v1/profile
Auth required
Soft-delete the authenticated user's own account.
Cascades bookmarks and sessions; error reports are kept with the user reference nulled.
{
"password": "secret123"
}
{
"data": {
"message": "Account deleted successfully"
}
}
No token required — this is what a visitor sees before creating an account.
/api/v1/exam-types
Public
List enabled exam types in display order.
30 req/min.
{
"data": [
{
"id": 1,
"code": "JAMB",
"title": "JAMB / UTME",
"description": "Joint Admissions and Matriculation Board examination.",
"image_url": null,
"icon": "GraduationCap",
"color": "indigo",
"allowed_modes": [
"practice",
"exam"
],
"default_duration": 120
}
]
}
/api/v1/subjects
Public
List subjects, optionally filtered by exam type.
30 req/min. Query: `?examType=JAMB`. Includes the distinct years questions exist for.
{
"data": [
{
"id": 3,
"name": "Chemistry",
"code": "CHM",
"question_count": 420,
"years": [
2020,
2021,
2022,
2023
],
"topics": [
"Atomic Structure",
"Organic Chemistry"
]
}
]
}
/api/v1/settings
Public
Platform-wide settings a landing page needs: branding, Pro pricing, footer, full Landing Page CMS content.
30 req/min. Whitelisted to exactly 4 sections (general, footer, landing_page, pro) — every field within those sections is returned in full, ships with real placeholder copy by default so an unconfigured install still renders a complete page.
{
"data": {
"general": {
"platform_name": "APC CBT Practice",
"tagline": "Practice smarter, score higher.",
"logo_url": "",
"primary_color": "#027a48"
},
"footer": {
"text": "",
"links": [],
"social": {
"twitter": "",
"facebook": "",
"instagram": ""
}
},
"landing_page": {
"hero": {
"title": "Ace Every Examination",
"image_6f": ""
}
},
"pro": {
"enabled": true,
"price": 0,
"currency": "NGN"
}
}
}
A server-owned save-for-later list, replacing the old client-only cached array.
/api/v1/bookmarks
Auth required
List the authenticated user's bookmarked question IDs.
IDs only — pair with GET /questions?ids= to hydrate full question content.
{
"data": {
"question_ids": [
501,
733,
902
]
}
}
/api/v1/bookmarks/{question}
Auth required
Bookmark a question.
Idempotent — calling it twice never creates a duplicate or errors. `{question}` is the question ID in the URL.
{
"data": {
"message": "Bookmarked."
}
}
/api/v1/bookmarks/{question}
Auth required
Remove one bookmark.
Returns success even if it was never bookmarked.
{
"data": {
"message": "Bookmark removed."
}
}
/api/v1/bookmarks
Auth required
Clear every bookmark for the authenticated user.
One query — not a loop.
{
"data": {
"message": "Bookmarks cleared."
}
}
A server-owned personal notebook — replaces what used to be a localStorage-only feature that vanished on a cleared browser or a second device.
/api/v1/notes
Auth required
List the authenticated user's notes, most recently updated first.
{
"data": [
{
"id": 12,
"title": "Quadratic formula",
"content": "x = (-b \u00b1 \u221a(b\u00b2-4ac)) / 2a",
"category": "Formula",
"created_at": "2026-07-20T10:00:00Z",
"updated_at": "2026-07-20T10:00:00Z"
}
]
}
/api/v1/notes
Auth required
Create a note.
`content` and `category` are optional/nullable — only `title` is required.
{
"title": "Quadratic formula",
"content": "x = (-b \u00b1 \u221a(b\u00b2-4ac)) / 2a",
"category": "Formula"
}
{
"data": {
"id": 12,
"title": "Quadratic formula",
"content": "x = (-b \u00b1 \u221a(b\u00b2-4ac)) / 2a",
"category": "Formula"
}
}
/api/v1/notes/{note}
Auth required
Update one of the authenticated user's own notes.
403 if `{note}` belongs to a different user — never a 404 that would leak whether the ID exists.
{
"title": "Quadratic formula (updated)",
"content": "...",
"category": "Formula"
}
{
"data": {
"id": 12,
"title": "Quadratic formula (updated)"
}
}
/api/v1/notes/{note}
Auth required
Delete one of the authenticated user's own notes.
403 if it belongs to a different user.
{
"data": {
"message": "Note deleted."
}
}
Real aggregate ranking over completed exam sessions — not a demo/mock list.
/api/v1/leaderboard
Auth required
Top students ranked by their best completed-exam score.
Query: `?examType=JAMB` (optional). Students only (admin accounts are excluded), scored only on `status=completed` sessions — in-progress/paused sessions never count. Capped at the top 50.
{
"data": [
{
"id": 42,
"name": "Ada Obi",
"avatar_url": null,
"exam_type": "JAMB",
"best_score": 96,
"average_score": 88.4,
"exams_count": 12
}
]
}
The hydration half of the bookmarks "IDs first, then hydrate" pattern.
/api/v1/questions
Auth required
Fetch full question content (with correct answer + explanation) for a set of IDs.
Query: `?ids=501,733,902`. Only IDs the caller has bookmarked are returned — never another student's.
{
"data": [
{
"id": 501,
"subject": {
"name": "Chemistry",
"code": "CHM"
},
"exam_type": {
"code": "JAMB",
"title": "JAMB / UTME"
},
"type": "mcq",
"topic": "Organic Chemistry",
"year": 2022,
"difficulty": "medium",
"content": "Which of the following is the correct IUPAC name for CH3COOH?",
"option_a": "Methanoic acid",
"option_b": "Ethanoic acid",
"option_c": "Propanoic acid",
"option_d": "Butanoic acid",
"correct_answer": "B",
"explanation": "CH3COOH has two carbons, so the -oic acid stem is \"ethan-\"."
}
]
}
Powers the Electron desktop client's offline-first local cache — a thin "everything changed since" pull per resource, so a device that was offline for days catches up with one request per resource instead of re-downloading everything.
/api/v1/sync/{resource}
Auth required
Pull every row of one resource changed since a given timestamp.
`{resource}` is one of: subjects, exam_types, questions, settings. Query: `?since=2026-07-19T10:00:00Z` (omit `since` on first sync to pull everything). Always use the response's own `synced_at` as your next cursor — never the max row timestamp you received, since a row can commit microseconds after the query ran but within the same second as one you already have.
{
"data": [
{
"id": 3,
"name": "Chemistry",
"code": "CHM",
"icon": null,
"color": null,
"description": "...",
"order": 1,
"topics": [
{
"id": 12,
"name": "Organic Chemistry"
}
],
"exam_types": [
"JAMB",
"WAEC"
],
"updated_at": "2026-07-20T09:12:00Z"
}
],
"synced_at": "2026-07-20T12:00:00Z"
}
The core exam-taking lifecycle: pick a configuration, take the exam, pause and resume, or walk away.
/api/v1/exams/{exam}
Auth required
Fetch a published exam blueprint.
Draft blueprints return 403. `{exam}` is the blueprint ID.
{
"data": {
"id": 7,
"display_name": "JAMB / UTME",
"exam_type": {
"id": 1,
"code": "JAMB",
"title": "JAMB / UTME"
},
"duration": 120,
"mode": "exam",
"subject_ids": [
3,
5,
8,
11
],
"questions_per_subject": 40,
"description": "Full JAMB mock, 4 subjects.",
"shuffle_questions": true,
"shuffle_options": true
}
}
/api/v1/exams/setup
Auth required
Start a new exam session.
If exam_id is present, the blueprint's subjects/duration/mode override whatever else was submitted. `duration` is capped at 480 (minutes) server-side regardless of what's submitted. For non-pro accounts, when Settings → Pro is enabled and `mode` is `practice`, this also enforces the admin-configured daily practice-exam cap (`pro.free_limits.exams_per_day`, 0 = unlimited) — a 403 is returned once that count is reached for the current calendar day; `exam` mode and pro accounts are never capped.
{
"exam_type_id": 1,
"subject_ids": [
3,
5
],
"duration": 60,
"mode": "practice",
"shuffle_questions": true,
"shuffle_options": false,
"exam_id": null
}
{
"data": {
"id": 981,
"exam_type": {
"code": "JAMB",
"title": "JAMB / UTME"
},
"exam_id": null,
"mode": "practice",
"duration": 60,
"status": "in_progress",
"subject_ids": [
3,
5
],
"total_possible": null,
"shuffle_questions": true,
"shuffle_options": false,
"started_at": "2026-07-20T12:00:00Z"
}
}
/api/v1/exams/{session}/start
Auth required
Fetch the question set for an in-progress session.
Structurally answer-free. Free-tier accounts get a capped question count per subject. `{session}` is the session ID.
{
"data": {
"session_id": 981,
"exam_type": {
"code": "JAMB",
"title": "JAMB / UTME"
},
"mode": "practice",
"duration": 60,
"total_questions": 20,
"questions": [
{
"id": 501,
"subject": {
"name": "Chemistry",
"code": "CHM"
},
"topic": "Organic Chemistry",
"year": 2022,
"difficulty": "medium",
"type": "mcq",
"content": "Which of the following is the correct IUPAC name for CH3COOH?",
"option_a": "Methanoic acid",
"option_b": "Ethanoic acid",
"option_c": "Propanoic acid",
"option_d": "Butanoic acid",
"media_url": null
}
],
"saved_answers": {
"501": "B"
}
}
}
/api/v1/exams/{session}/time
Auth required
Lightweight poll for session status and any admin-granted extra time.
Meant to be called every ~30 seconds by the exam screen.
{
"data": {
"status": "in_progress",
"extra_time": 0
}
}
/api/v1/exams/{session}/pause
Auth required
Pause an in-progress session.
Records the client-reported time remaining.
{
"time_remaining": 2140
}
{
"data": {
"id": 981,
"status": "paused",
"exam_type": {
"code": "JAMB",
"title": "JAMB / UTME"
},
"mode": "practice",
"duration": 60,
"subject_ids": [
3,
5
],
"started_at": "2026-07-20T12:00:00Z"
}
}
/api/v1/exams/{session}/resume
Auth required
Resume a paused session.
The server's stored time remaining is authoritative — never the client's own clock.
{
"data": {
"time_remaining": 2140
}
}
/api/v1/exams/{session}/violation
Auth required
Log a proctoring violation (tab-switch) during exam mode.
Never auto-submits — the client alone decides when 3 violations means "submit now."
{
"type": "tab_switch",
"count": 2
}
{
"data": {
"message": "Violation logged."
}
}
/api/v1/exams/{session}/submit
Auth required
Submit answers and grade the session.
A session can only be graded once. time_spent is computed server-side, never trusted from the client. Each `subject_results` entry's `subject_name` falls back to `"Unknown Subject"` rather than `null` if the question's subject was since deleted.
{
"answers": [
{
"question_id": 501,
"selected_answer": "B"
},
{
"question_id": 733,
"selected_answer": null
}
]
}
{
"data": {
"session": {
"id": 981,
"percentage": 80,
"total_score": 16,
"total_possible": 20,
"time_spent": 2350
},
"subject_results": [
{
"subject_id": 3,
"subject_name": "Chemistry",
"correct": 9,
"total": 10
}
],
"answers_submitted": 20
}
}
/api/v1/exams/{session}/results
Auth required
Fetch the full, answer-revealing results for a completed session.
The one place correct_answer and explanation are ever returned to a student. Note: each `subject_results` entry's subject name is keyed `name` here — NOT `subject_name` like the `/submit` response above uses — these are two independently-built payloads, don't assume the same shape. `name` (and `code`) fall back to `"Unknown Subject"`/`null` if the question's subject was since deleted (orphaned data), rather than ever returning `null` for a field the contract promises as a string.
{
"data": {
"session": {
"id": 981,
"status": "completed",
"percentage": 80,
"total_score": 16,
"total_possible": 20,
"time_spent": 2350,
"completed_at": "2026-07-20T12:39:10Z"
},
"subject_results": [
{
"subject_id": 3,
"name": "Chemistry",
"code": "CHM",
"color": "#3B82F6",
"correct": 9,
"total": 10,
"time_spent": 640,
"percentage": 90
}
],
"answers": [
{
"id": 501,
"subject": {
"name": "Chemistry",
"code": "CHM"
},
"content": "Which of the following is the correct IUPAC name for CH3COOH?",
"option_a": "Methanoic acid",
"option_b": "Ethanoic acid",
"correct_answer": "B",
"selected_answer": "B",
"is_correct": true,
"explanation": "CH3COOH has two carbons...",
"time_spent": 117
}
]
}
}
/api/v1/exams/{session}
Auth required
Discard an abandoned session.
Only while in progress or paused.
{
"data": {
"message": "Session discarded."
}
}
/api/v1/exams/history
Auth required
List the authenticated user's past exam sessions.
{
"data": [
{
"id": 981,
"exam_type": {
"code": "JAMB",
"title": "JAMB / UTME"
},
"mode": "practice",
"status": "completed",
"percentage": 80,
"total_score": 16,
"total_possible": 20,
"time_spent": 2350,
"started_at": "2026-07-20T12:00:00Z",
"completed_at": "2026-07-20T12:39:10Z"
}
]
}
/api/v1/exams/performance
Auth required
Per-topic accuracy for one exam type.
Query: `?examType=JAMB`. A first-time student with no history gets an empty result, not an error.
{
"data": {
"topic_stats": [
{
"topic": "Organic Chemistry",
"correct": 28,
"total": 40,
"percentage": 70
},
{
"topic": "Atomic Structure",
"correct": 11,
"total": 40,
"percentage": 27.5
}
]
}
}
How a student unlocks Pro — a redeemable key, a mock local flow, or a real Paystack payment.
/api/v1/license/activate
Auth required
Redeem a license key to unlock Pro access.
Row-locked against the same key being redeemed twice in a race.
{
"activation_key": "APC-7F3K-2QRT-88ZN",
"machine_id": "DESKTOP-7F3K2Q"
}
{
"data": {
"message": "Pro access activated.",
"is_pro": true
}
}
/api/v1/license/paystack/initialize
Auth required
Start a real Paystack payment for a Pro key.
{
"callback_url": "https://app.example.com/payment/callback"
}
{
"data": {
"authorization_url": "https://checkout.paystack.com/abc123",
"access_code": "abc123",
"reference": "apc_ref_9f2c1a"
}
}
/api/v1/license/paystack-mock
Auth required
Simulate a successful payment without a real charge.
Local development only.
{
"reference": "apc_ref_9f2c1a",
"machine_id": "DESKTOP-7F3K2Q"
}
{
"data": {
"message": "Pro access activated.",
"is_pro": true
}
}
/api/v1/webhooks/paystack
Public
Paystack's server-to-server payment confirmation callback.
Signature-verified via the `x-paystack-signature` header; never callable as a normal client request.
{
"data": {
"message": "Webhook processed."
}
}
Platform-wide numbers for the admin overview screen.
/api/v1/admin/stats
Admin only
Overview, score distribution, exam-type popularity, and recent activity.
Query: `?range=7d|30d|90d`. Cached briefly per range.
{
"data": {
"overview": {
"total_users": 4200,
"total_sessions": 15300,
"sessions_today": 210
},
"trend": [
{
"date": "2026-07-19",
"sessions": 190
}
],
"score_distribution": [
{
"band": "80-100",
"count": 1200
}
]
}
}
/api/v1/admin/stats/export
Admin only
Export the same overview as CSV, XLSX, or PDF.
Query: `?format=csv|xlsx|pdf&range=30d`.
The exam-type catalog (JAMB, WAEC, …) students choose from at setup.
/api/v1/admin/exam-types
Admin only
List exam types with session counts.
{
"data": [
{
"id": 1,
"code": "JAMB",
"title": "JAMB / UTME",
"description": "...",
"image_url": null,
"icon": "GraduationCap",
"color": "indigo",
"allowed_modes": [
"practice",
"exam"
],
"default_duration": 120,
"enabled": true,
"order": 1,
"session_count": 15300,
"created_at": "2026-01-01T00:00:00Z"
}
]
}
/api/v1/admin/exam-types
Admin only
Create an exam type.
`icon` is a Lucide icon name (e.g. `GraduationCap`, `BookOpen`) shown on the student dashboard tile whenever no card image is uploaded — nullable, free string, not validated against the actual Lucide icon set.
{
"code": "GCE",
"title": "GCE O/Level",
"description": "General Certificate of Education.",
"default_duration": 90,
"color": "teal",
"icon": "BookOpen",
"allowed_modes": [
"practice",
"exam"
],
"enabled": true
}
{
"data": {
"id": 6,
"code": "GCE",
"title": "GCE O/Level",
"icon": "BookOpen",
"enabled": true,
"order": 6
}
}
/api/v1/admin/exam-types/{exam_type}
Admin only
Fetch one exam type.
{
"data": {
"id": 1,
"code": "JAMB",
"title": "JAMB / UTME"
}
}
/api/v1/admin/exam-types/{exam_type}
Admin only
Update an exam type.
`code` cannot be changed — omit it, it is ignored if sent.
{
"title": "JAMB / UTME",
"description": "Updated description.",
"default_duration": 150,
"color": "indigo",
"icon": "GraduationCap",
"allowed_modes": [
"practice",
"exam"
],
"enabled": true
}
{
"data": {
"id": 1,
"title": "JAMB / UTME",
"default_duration": 150,
"icon": "GraduationCap"
}
}
/api/v1/admin/exam-types/{exam_type}
Admin only
Delete an exam type.
Blocked (409) while it still has sessions or questions attached.
{
"data": {
"message": "Exam type deleted."
}
}
/api/v1/admin/exam-types/reorder
Admin only
Reorder the exam-type list.
Atomic — a partial list leaves untouched rows' order alone.
{
"ids": [
3,
1,
2,
5,
4
]
}
{
"data": {
"message": "Order updated."
}
}
/api/v1/admin/exam-types/{examType}/image
Admin only
Upload the exam type's card image.
multipart/form-data, field `image` (png/jpg/jpeg/webp/svg/gif, max 2MB). SVGs are sanitized against script injection.
{
"image": "(binary file)"
}
{
"data": {
"image_url": "https://api.example.com/storage/exam-type-cards/1.png"
}
}
/api/v1/admin/exam-types/{examType}/toggle
Admin only
Flip an exam type enabled/disabled.
No request body.
{
"data": {
"id": 1,
"enabled": false
}
}
/api/v1/admin/exam-types/{code}/monitor
Admin only
Live monitor for one exam type: who's writing now, today's completions, live averages.
`{code}` is the exam type code, e.g. JAMB.
{
"data": {
"writing_now": 42,
"completed_today": 318,
"avg_score": 68.4,
"avg_duration": 4120,
"sessions": [
{
"id": 981,
"student": {
"name": "Ada Obi"
},
"status": "in_progress",
"time_remaining_seconds": 1840
}
]
}
}
Subjects and their topics, pivoted per exam type.
/api/v1/admin/subjects
Admin only
List and search subjects.
Query: `?search=chem&examType=JAMB`.
{
"data": [
{
"id": 3,
"name": "Chemistry",
"code": "CHM",
"description": "...",
"icon": null,
"color": null,
"exam_types": [
"JAMB",
"WAEC"
],
"question_count": 420,
"topic_count": 12,
"order": 1,
"created_at": "2026-01-01T00:00:00Z"
}
]
}
/api/v1/admin/subjects
Admin only
Create a subject with its topics.
`icon` (Lucide icon name) and `color` (any CSS color string) are both optional and nullable.
{
"name": "Chemistry",
"code": "CHM",
"exam_type_ids": [
1,
2
],
"description": "Physical & organic chemistry",
"icon": "FlaskConical",
"color": "#0EA5E9",
"topics": [
"Atomic Structure",
"Organic Chemistry"
]
}
{
"data": {
"id": 3,
"name": "Chemistry",
"code": "CHM",
"icon": "FlaskConical",
"color": "#0EA5E9",
"topics": [
"Atomic Structure",
"Organic Chemistry"
]
}
}
/api/v1/admin/subjects/{subject}
Admin only
Fetch one subject.
{
"data": {
"id": 3,
"name": "Chemistry",
"code": "CHM"
}
}
/api/v1/admin/subjects/{subject}
Admin only
Update a subject, reconciling its topic list.
Topic names not present in the submitted list are removed; surviving ones keep their IDs. Questions store their topic as a free-text string, not a foreign key — swapping exactly one old name for exactly one new one is treated as a rename and cascades onto every question still tagged with the old name. Removing a name outright (no 1:1 replacement) is rejected (422, field `topics`) if any question still references it — reassign or delete those questions first, rather than silently orphaning them.
{
"name": "Chemistry",
"code": "CHM",
"exam_type_ids": [
1,
2
],
"icon": "FlaskConical",
"color": "#0EA5E9",
"topics": [
"Atomic Structure",
"Organic Chemistry",
"Electrochemistry"
]
}
{
"data": {
"id": 3,
"name": "Chemistry",
"topics": [
"Atomic Structure",
"Organic Chemistry",
"Electrochemistry"
]
}
}
/api/v1/admin/subjects/{subject}
Admin only
Delete a subject.
Rejected (400) while it still has questions attached.
{
"data": {
"message": "Subject deleted."
}
}
/api/v1/admin/subjects
Admin only
Bulk-delete subjects by ID.
Skips (and reports) any subject that still has questions, rather than failing the whole batch. Query: `?ids=3,4,9`.
{
"data": {
"deleted": [
4,
9
],
"skipped": [
{
"id": 3,
"reason": "Has 420 questions attached."
}
]
}
}
/api/v1/admin/subjects/reorder
Admin only
Reorder subjects.
{
"ids": [
5,
3,
1
]
}
{
"data": {
"message": "Order updated."
}
}
/api/v1/admin/subjects/export
Admin only
Export subjects as CSV, XLSX, or PDF.
Query: `?format=csv|xlsx|pdf`.
/api/v1/admin/subjects/import
Admin only
Preview a subject CSV/XLSX import before committing.
multipart/form-data, field `file`.
{
"file": "(binary file)"
}
{
"data": {
"valid_rows": 18,
"invalid_rows": [
{
"row": 4,
"errors": {
"code": [
"The code has already been taken."
]
}
}
],
"token": "import_9f2c1a"
}
}
/api/v1/admin/subjects/import/commit
Admin only
Commit a previously previewed subject import.
Re-validates every row at commit time.
{
"token": "import_9f2c1a"
}
{
"data": {
"created": 17,
"skipped": 1
}
}
The question content itself — always scoped to one exam type at a time.
/api/v1/admin/questions
Admin only
List questions for one exam type.
Query: `?filter[examType]=JAMB&filter[subjectId]=3&filter[difficulty]=medium&filter[topic]=organic` — examType required, refuses an unscoped query. subjectId/difficulty are exact-match, topic is a partial (contains) match.
{
"data": [
{
"id": 501,
"subject": {
"id": 3,
"name": "Chemistry",
"code": "CHM"
},
"exam_type": {
"id": 1,
"code": "JAMB",
"title": "JAMB / UTME"
},
"type": "mcq",
"topic": "Organic Chemistry",
"year": 2022,
"difficulty": "medium",
"content": "...",
"option_a": "Methanoic acid",
"option_b": "Ethanoic acid",
"option_c": "Propanoic acid",
"option_d": "Butanoic acid",
"correct_answer": "B",
"explanation": "...",
"media_url": null
}
]
}
/api/v1/admin/questions
Admin only
Create a question.
Required fields shift per `type` (mcq/true_false/fill_in/video/picture/matching/essay). Example shown is `type=mcq`. `type=true_false` only ever uses `option_a`/`option_b` (default to 'True'/'False' if left blank) and `correct_answer` in:A,B — `option_c`/`option_d` are `prohibited` (validation fails if sent, even as empty strings) for this type, not merely optional. `type=video` requires `media_url`.
{
"exam_type_id": 1,
"subject_id": 3,
"type": "mcq",
"topic": "Organic Chemistry",
"difficulty": "medium",
"year": 2022,
"content": "Which of the following is the correct IUPAC name for CH3COOH?",
"option_a": "Methanoic acid",
"option_b": "Ethanoic acid",
"option_c": "Propanoic acid",
"option_d": "Butanoic acid",
"correct_answer": "B",
"explanation": "CH3COOH has two carbons..."
}
{
"data": {
"id": 501,
"type": "mcq",
"content": "..."
}
}
/api/v1/admin/questions/{question}
Admin only
Fetch one question.
{
"data": {
"id": 501,
"type": "mcq"
}
}
/api/v1/admin/questions/{question}
Admin only
Update a question.
Switching type clears whichever option/answer fields no longer apply. `explanation` and `media_url` are whole-value replaces, not merges — to clear a previously-set explanation, send `"explanation": null` (or `""`) explicitly; omitting the key entirely leaves the existing saved value untouched.
{
"exam_type_id": 1,
"subject_id": 3,
"type": "mcq",
"topic": "Organic Chemistry",
"difficulty": "hard",
"content": "...",
"option_a": "Methanoic acid",
"option_b": "Ethanoic acid",
"option_c": "Propanoic acid",
"option_d": "Butanoic acid",
"correct_answer": "B"
}
{
"data": {
"id": 501,
"difficulty": "hard"
}
}
/api/v1/admin/questions/{question}
Admin only
Delete a question.
Its recorded answers are deleted with it.
{
"data": {
"message": "Question deleted."
}
}
/api/v1/admin/questions/bulk
Admin only
Bulk-update difficulty and/or subject for a set of questions.
All-or-nothing — a reassignment that would break a subject/exam-type pairing rejects the whole batch.
{
"ids": [
501,
502,
503
],
"difficulty": "hard"
}
{
"data": {
"updated": 3
}
}
/api/v1/admin/questions
Admin only
Bulk-delete questions by ID.
Query: `?ids=501,502`.
{
"data": {
"deleted": 2
}
}
/api/v1/admin/questions/export
Admin only
Export questions as CSV, XLSX, or answer-free PDF.
Query: `?format=csv|xlsx|pdf&showAnswers=0|1`.
/api/v1/admin/questions/import
Admin only
Preview a question CSV/XLSX import.
multipart/form-data, field `file`. mcq-only for import; content is sanitized before validation.
{
"file": "(binary file)"
}
{
"data": {
"valid_rows": 40,
"invalid_rows": [],
"token": "import_a1b2c3"
}
}
/api/v1/admin/questions/import/commit
Admin only
Commit a previously previewed question import.
{
"token": "import_a1b2c3"
}
{
"data": {
"created": 40
}
}
Pre-configured exam templates a student can launch by ID instead of configuring one manually.
/api/v1/admin/exams
Admin only
List exam blueprints.
{
"data": [
{
"id": 7,
"display_name": "JAMB / UTME",
"exam_type": {
"id": 1,
"code": "JAMB",
"title": "JAMB / UTME"
},
"duration": 120,
"mode": "exam",
"subject_ids": [
3,
5,
8,
11
],
"subject_count": 4,
"status": "published",
"created_at": "2026-01-05T00:00:00Z"
}
]
}
/api/v1/admin/exams
Admin only
Create a blueprint.
Mode and subjects are cross-checked against the exam type's own allowances.
{
"exam_type_id": 1,
"duration": 120,
"subject_ids": [
3,
5,
8,
11
],
"mode": "exam",
"questions_per_subject": 40,
"description": "Full JAMB mock, 4 subjects.",
"shuffle_questions": true,
"shuffle_options": true,
"status": "draft"
}
{
"data": {
"id": 7,
"status": "draft"
}
}
/api/v1/admin/exams/{exam}
Admin only
Fetch one blueprint.
{
"data": {
"id": 7,
"status": "draft"
}
}
/api/v1/admin/exams/{exam}
Admin only
Update a blueprint.
{
"exam_type_id": 1,
"duration": 120,
"subject_ids": [
3,
5,
8,
11
],
"mode": "exam",
"status": "published"
}
{
"data": {
"id": 7,
"status": "published"
}
}
/api/v1/admin/exams/{exam}
Admin only
Delete a blueprint.
Sessions already started from it keep their own copy of the configuration.
{
"data": {
"message": "Exam blueprint deleted."
}
}
/api/v1/admin/exams/bulk-status
Admin only
Bulk publish or unpublish blueprints.
{
"ids": [
7,
8
],
"status": "published"
}
{
"data": {
"updated": 2
}
}
/api/v1/admin/exams
Admin only
Bulk-delete blueprints by ID.
Query: `?ids=7,8`.
{
"data": {
"deleted": 2
}
}
/api/v1/admin/exams/export
Admin only
Export blueprints as CSV, XLSX, or PDF.
Query: `?format=csv|xlsx|pdf`.
/api/v1/admin/exams/import
Admin only
Preview a blueprint CSV/XLSX import.
multipart/form-data, field `file`.
{
"file": "(binary file)"
}
{
"data": {
"valid_rows": 5,
"invalid_rows": [],
"token": "import_e5f6g7"
}
}
/api/v1/admin/exams/import/commit
Admin only
Commit a previously previewed blueprint import.
{
"token": "import_e5f6g7"
}
{
"data": {
"created": 5
}
}
Every registered account, student and admin alike.
/api/v1/admin/users
Admin only
List and search users, with session and question-answered counts.
Query: `?search=ada&role=student&examType=JAMB`.
{
"data": [
{
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com",
"phone": null,
"exam_type": "JAMB",
"role": "student",
"exam_sessions_count": 12,
"answered_questions_count": 240,
"total_questions_available_count": 4200,
"created_at": "2026-01-01T00:00:00Z"
}
]
}
/api/v1/admin/users
Admin only
Create a user directly (no self-registration flow).
{
"name": "Chinedu Okoye",
"email": "chinedu@example.com",
"role": "student",
"exam_type": "WAEC",
"phone": null,
"password": "secret123"
}
{
"data": {
"id": 88,
"name": "Chinedu Okoye",
"role": "student"
}
}
/api/v1/admin/users/{user}
Admin only
Fetch one user's detail, including their recent sessions.
{
"data": {
"id": 42,
"name": "Ada Obi",
"exam_sessions_count": 12,
"recent_sessions": [
{
"id": 981,
"exam_type": {
"title": "JAMB / UTME"
},
"mode": "practice",
"percentage": 80,
"status": "completed",
"started_at": "2026-07-20T12:00:00Z"
}
]
}
}
/api/v1/admin/users/{user}
Admin only
Update a user, including role.
Leaving `password` blank keeps the existing one.
{
"name": "Ada Obi",
"email": "ada@example.com",
"role": "admin",
"exam_type": "JAMB",
"phone": null
}
{
"data": {
"id": 42,
"role": "admin"
}
}
/api/v1/admin/users/{user}
Admin only
Delete a user, cascading their bookmarks and sessions.
Refused (409) if it would leave the platform with zero admins.
{
"data": {
"message": "User deleted."
}
}
/api/v1/admin/users/bulk-role
Admin only
Bulk-change role for a set of users.
Same zero-admins guard as single delete.
{
"ids": [
42,
88
],
"role": "student"
}
{
"data": {
"updated": 2
}
}
/api/v1/admin/users
Admin only
Bulk-delete users by ID.
Query: `?ids=42,88`.
{
"data": {
"deleted": 2
}
}
/api/v1/admin/users/import
Admin only
Preview a user CSV/XLSX import.
multipart/form-data, field `file`.
{
"file": "(binary file)"
}
{
"data": {
"valid_rows": 30,
"invalid_rows": [],
"token": "import_h8i9j0"
}
}
/api/v1/admin/users/import/commit
Admin only
Commit a previously previewed user import.
{
"token": "import_h8i9j0"
}
{
"data": {
"created": 30
}
}
A read/audit view over every exam session, plus the two interventions an admin can make on a live one.
/api/v1/admin/sessions
Admin only
List and filter exam sessions across every student.
Query: `?status=in_progress&minScore=50&startedFrom=2026-07-01`.
{
"data": [
{
"id": 981,
"student": {
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com"
},
"exam_type": {
"code": "JAMB",
"title": "JAMB / UTME"
},
"mode": "practice",
"status": "completed",
"percentage": 80,
"started_at": "2026-07-20T12:00:00Z",
"completed_at": "2026-07-20T12:39:10Z"
}
]
}
/api/v1/admin/sessions/{session}
Admin only
Fetch one session's full detail.
{
"data": {
"id": 981,
"status": "completed",
"percentage": 80
}
}
/api/v1/admin/sessions/{session}
Admin only
Delete a session and its recorded answers.
{
"data": {
"message": "Session deleted."
}
}
/api/v1/admin/sessions
Admin only
Bulk-delete sessions by ID.
Query: `?ids=981,982`.
{
"data": {
"deleted": 2
}
}
/api/v1/admin/sessions/export
Admin only
Export sessions as CSV, XLSX, or PDF, respecting active filters.
Query: `?format=csv|xlsx|pdf`. Capped at 10,000 rows.
/api/v1/admin/sessions/{session}/extra-time
Admin only
Grant extra time to a session in progress.
{
"minutes": 15
}
{
"data": {
"id": 981,
"extra_time": 900
}
}
/api/v1/admin/sessions/{session}/force-submit
Admin only
Force-submit and grade a session on the student's behalf.
Uses the same grading logic (GradeExamAction) as a normal student submission.
{
"data": {
"session": {
"id": 981,
"status": "completed",
"percentage": 62.5
},
"subject_results": [],
"answers_submitted": 20
}
}
Read-only visibility into student-generated activity that has no other admin surface — what they write down, what they save, and how they rank.
/api/v1/admin/notes
Admin only
List every student note, across every account.
Paginated (Spatie QueryBuilder, 15/page). Query: `?filter[title]=formula&filter[category]=Formula&filter[userId]=42&sort=-created_at`.
{
"data": [
{
"id": 12,
"title": "Quadratic formula",
"content": "x = (-b \u00b1 \u221a(b\u00b2-4ac)) / 2a",
"category": "Formula",
"user": {
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com"
},
"created_at": "2026-07-20T10:00:00Z",
"updated_at": "2026-07-20T10:00:00Z"
}
]
}
/api/v1/admin/bookmarks
Admin only
List every bookmarked question, across every account.
Paginated. Query: `?filter[examType]=JAMB&filter[subjectId]=3&filter[userId]=42&filter[questionId]=501`.
{
"data": [
{
"id": 88,
"user": {
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com"
},
"question": {
"id": 501,
"content": "...",
"topic": "Organic Chemistry",
"subject": "Chemistry",
"exam_type": "JAMB"
},
"created_at": "2026-07-20T10:00:00Z"
}
]
}
/api/v1/admin/leaderboard
Admin only
Same ranking the student-facing leaderboard shows, with email included.
Query: `?examType=JAMB&limit=100` (limit capped at 200, default 100 — higher than the student-facing endpoint's fixed 50, for monitoring rather than bragging rights).
{
"data": [
{
"id": 42,
"name": "Ada Obi",
"email": "ada@example.com",
"avatar_url": null,
"exam_type": "JAMB",
"best_score": 96,
"average_score": 88.4,
"exams_count": 12
}
]
}
Question-quality issues flagged by students, snapshotted so later question edits can't rewrite history.
/api/v1/admin/error-reports
Admin only
List and filter error reports.
Query: `?status=open`.
{
"data": [
{
"id": 12,
"reporter": {
"name": "Ada Obi",
"email": "ada@example.com"
},
"subject_name": "Chemistry",
"exam_type": "JAMB",
"question_content": "Which of the following...",
"message": "Option B and C both look correct to me.",
"status": "open",
"created_at": "2026-07-19T08:00:00Z",
"resolved_at": null
}
]
}
/api/v1/admin/error-reports/{error_report}
Admin only
Fetch one report.
Reads entirely from its own snapshot, immune to the source question changing later.
{
"data": {
"id": 12,
"status": "open"
}
}
/api/v1/admin/error-reports/{error_report}
Admin only
Delete a report.
{
"data": {
"message": "Report deleted."
}
}
/api/v1/admin/error-reports/{errorReport}
Admin only
Mark a report resolved or reopen it.
{
"status": "resolved"
}
{
"data": {
"id": 12,
"status": "resolved",
"resolved_at": "2026-07-20T09:00:00Z"
}
}
/api/v1/admin/error-reports/bulk-status
Admin only
Bulk-update status for a set of reports.
{
"ids": [
12,
13
],
"status": "resolved"
}
{
"data": {
"updated": 2
}
}
/api/v1/admin/error-reports
Admin only
Bulk-delete reports by ID.
Query: `?ids=12,13`.
{
"data": {
"deleted": 2
}
}
/api/v1/admin/error-reports/export
Admin only
Export reports as CSV, XLSX, or PDF.
Query: `?format=csv|xlsx|pdf`.
Pro access keys — generated in bulk, printed as scratch cards, and revocable.
/api/v1/admin/license-keys
Admin only
List and filter license keys, with activation history.
Query: `?status=unused&type=single`.
{
"data": [
{
"id": 5,
"code": "APC-7F3K-2QRT-88ZN",
"max_uses": 1,
"used_count": 0,
"status": "unused",
"is_org": false,
"created_at": "2026-07-01T00:00:00Z"
}
]
}
/api/v1/admin/license-keys
Admin only
Generate one custom key or a batch of random ones.
Uses an unambiguous alphabet — no 0/O or 1/I confusion when printed.
{
"quantity": 50,
"max_uses": 1
}
{
"data": {
"generated": 50,
"keys": [
"APC-7F3K-2QRT-88ZN",
"..."
]
}
}
/api/v1/admin/license-keys/{license_key}
Admin only
Fetch one key's detail and activation history.
{
"data": {
"id": 5,
"code": "APC-7F3K-2QRT-88ZN",
"activations": [
{
"user": {
"name": "Ada Obi"
},
"machine_id": "DESKTOP-7F3K2Q",
"activated_at": "2026-07-05T10:00:00Z"
}
]
}
}
/api/v1/admin/license-keys/{license_key}
Admin only
Revoke a key.
Never retroactively removes Pro access already granted from it.
{
"data": {
"message": "License key revoked."
}
}
/api/v1/admin/license-keys/print
Admin only
Render a batch of keys as printable scratch cards (PDF).
Query: `?ids=5,6,7` (max 100).
Whole-section platform configuration — branding, registration rules, Pro pricing, email, footer, landing page copy — plus asset uploads for logo/favicon/hero images.
/api/v1/admin/settings/{section}
Admin only
Fetch one settings section.
Section is one of: general, registration, pro, email, footer, system, landing_page.
{
"data": {
"platform_name": "APC CBT Practice",
"support_email": "support@cbtpractice.com",
"tagline": "Practice smarter, score higher.",
"logo_url": "",
"favicon_url": "",
"primary_color": "#027a48"
}
}
/api/v1/admin/settings/{section}
Admin only
Replace a settings section.
Whole-section replace — omitted fields revert to platform defaults, they do not merge.
{
"values": {
"platform_name": "APC CBT Practice",
"support_email": "help@cbtpractice.com",
"tagline": "Practice smarter, score higher.",
"primary_color": "#027a48"
}
}
{
"data": {
"platform_name": "APC CBT Practice",
"support_email": "help@cbtpractice.com"
}
}
/api/v1/admin/settings/{section}/reset
Admin only
Reset one section back to platform defaults.
No request body.
{
"data": {
"platform_name": "APC CBT Practice",
"tagline": "Practice smarter, score higher."
}
}
/api/v1/admin/settings/email/test
Admin only
Send a real test email using the saved SMTP settings.
No request body.
{
"data": {
"message": "Test email sent."
}
}
/api/v1/admin/settings/upload
Admin only
Upload a logo, favicon, or landing-page hero image and get back a storage URL.
multipart/form-data, not JSON — fields: `file` (png/jpg/jpeg/webp/svg/gif/ico, max 2MB) and `type` (one of `logo`, `favicon`, `hero`). SVGs are sanitized, ICOs stored as-is, other rasters resized to max width 1600px. Returns the URL only — save it into the relevant settings section yourself with a follow-up PUT (e.g. `general.logo_url`, `landing_page.hero.image_6f`).
{
"file": "(binary \u2014 multipart upload)",
"type": "logo"
}
{
"data": {
"url": "http://127.0.0.1:8000/storage/settings-assets/logo/0ba66691-610d-4690-a9d7-dfc16198161b.png"
}
}
Generic asset upload backing every rich-text editor in the admin panel (question explanations, etc.) — both the toolbar's image-insert button and paste-to-upload use this.
/api/v1/admin/upload
Admin only
Upload an image or video pasted/inserted into a rich-text editor and get back a storage URL.
multipart/form-data, not JSON — field `file` (png/jpg/jpeg/webp/svg/gif/mp4/webm/mov, max 10MB). SVGs are sanitized against script injection; videos are stored as-is; other rasters resized to max width 1600px. Returns the URL only — the editor inserts it into the document itself, no follow-up save call.
{
"file": "(binary \u2014 multipart upload)"
}
{
"data": {
"url": "http://127.0.0.1:8000/storage/content-uploads/2f1a9c3e-....png"
}
}
No endpoints match that filter.