Backend REST API · 119 endpoints

API Reference

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://www.exampracticecbt.rararaapcmegaapp.com.ng/api/v1.

Authentication

Bearer token via Laravel Sanctum.

Authorization: Bearer <token>

Roles

Every account is student or admin. Admin routes additionally require the admin gate.

Rate limits

Login 10/min · Register 5/min · Public catalog 30/min · everything else per authenticated user

Success response

{
  "data": { … }
}

Error response

{
  "message": "…",
  "errors": { … }
}

Integrating from React / Next.js

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.

1. A typed API client

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://www.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;
}

2. Login and store the token

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;
}

3. Call an authenticated endpoint

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,
    }),
  });
}

4. Server Components / SSR

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;
}

5. Handling 401s globally

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";
  }
}

Plain JavaScript / any other web framework

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.

Integrating from a mobile app

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.

React Native (Expo)

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://www.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;
}

Native Android (Kotlin)

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()

Native iOS (Swift)

Store the token in the Keychain, attach it per-request:

var request = URLRequest(url: URL(string: "https://www.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)

Flutter (Dart)

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://www.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> login(String email, String password) async {
  final result = await apiFetch('/auth/login', method: 'POST', body: {
    'email': email,
    'password': password,
  });
  await _storage.write(key: 'token', value: result['token']);
  return result['user'];
}

Uploads from a device (avatar, exam-type image)

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();

Offline-first exam sessions

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.

Integrating from Electron (Windows desktop, offline-first)

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.

1. Local schema mirrors the sync resources

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
);

2. Pull phase — one request per resource

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://www.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);
  }
}

3. Detecting connectivity and triggering a sync

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://www.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);
}

4. Push phase — queue writes made while offline

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://www.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
    }
  }
}

5. IPC boundary

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 };
});

System

Infrastructure-level, not part of the product surface.

GET /api/v1/health Public

Liveness check for uptime monitoring.

No auth, no rate limit.

Response 200 OK
{
    "data": {
        "status": "ok"
    }
}

Auth

Shared by both students and admins — role is decided server-side, never by the client.

POST /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.

Request body
{
    "name": "Ada Obi",
    "email": "ada@example.com",
    "password": "secret123"
}
Response 201 OK
{
    "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"
    }
}
POST /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.

Request body
{
    "email": "ada@example.com",
    "password": "secret123"
}
Response 200 OK
{
    "data": {
        "token": "1|abcdEFGH...redacted",
        "user": {
            "id": 42,
            "name": "Ada Obi",
            "email": "ada@example.com",
            "role": "student"
        }
    }
}
GET /api/v1/auth/me Auth required

Return the authenticated user's own profile.

Response 200 OK
{
    "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"
    }
}
POST /api/v1/auth/logout Auth required

Revoke the current access token only.

Other devices/tokens for the same user stay signed in.

Response 200 OK
{
    "data": {
        "message": "Logged out successfully"
    }
}

Profile

The authenticated user managing their own account. No endpoint here can touch role, email, or Pro status.

GET /api/v1/profile Auth required

Fetch the current user's profile plus lifetime stats.

Response 200 OK
{
    "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
                }
            ]
        }
    }
}
PUT /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.

Request body
{
    "name": "Ada N. Obi",
    "phone": "+2348012345678",
    "exam_type": "JAMB",
    "machine_id": "DESKTOP-7F3K2Q"
}
Response 200 OK
{
    "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"
    }
}
POST /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.

Request body
{
    "avatar": "(binary file)"
}
Response 200 OK
{
    "data": {
        "id": 42,
        "name": "Ada N. Obi",
        "avatar_url": "https://api.example.com/storage/avatars/42.jpg"
    }
}
POST /api/v1/profile/change-password Auth required

Change password given the current one.

The session/token stays valid after a successful change.

Request body
{
    "current_password": "secret123",
    "new_password": "evenBetter456"
}
Response 200 OK
{
    "data": {
        "message": "Password changed successfully"
    }
}
GET /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.

Response 200 OK
{
    "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"
    }
}
DELETE /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.

Request body
{
    "password": "secret123"
}
Response 200 OK
{
    "data": {
        "message": "Account deleted successfully"
    }
}

Public Catalog

No token required — this is what a visitor sees before creating an account.

GET /api/v1/exam-types Public

List enabled exam types in display order.

30 req/min.

Response 200 OK
{
    "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
        }
    ]
}
GET /api/v1/subjects Public

List subjects, optionally filtered by exam type.

30 req/min. Query: `?examType=JAMB`. Includes the distinct years questions exist for.

Response 200 OK
{
    "data": [
        {
            "id": 3,
            "name": "Chemistry",
            "code": "CHM",
            "question_count": 420,
            "years": [
                2020,
                2021,
                2022,
                2023
            ],
            "topics": [
                "Atomic Structure",
                "Organic Chemistry"
            ]
        }
    ]
}
GET /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.

Response 200 OK
{
    "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"
        }
    }
}

Bookmarks

A server-owned save-for-later list, replacing the old client-only cached array.

GET /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.

Response 200 OK
{
    "data": {
        "question_ids": [
            501,
            733,
            902
        ]
    }
}
POST /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.

Response 200 OK
{
    "data": {
        "message": "Bookmarked."
    }
}
DELETE /api/v1/bookmarks/{question} Auth required

Remove one bookmark.

Returns success even if it was never bookmarked.

Response 200 OK
{
    "data": {
        "message": "Bookmark removed."
    }
}
DELETE /api/v1/bookmarks Auth required

Clear every bookmark for the authenticated user.

One query — not a loop.

Response 200 OK
{
    "data": {
        "message": "Bookmarks cleared."
    }
}

Notes

A server-owned personal notebook — replaces what used to be a localStorage-only feature that vanished on a cleared browser or a second device.

GET /api/v1/notes Auth required

List the authenticated user's notes, most recently updated first.

Response 200 OK
{
    "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"
        }
    ]
}
POST /api/v1/notes Auth required

Create a note.

`content` and `category` are optional/nullable — only `title` is required.

Request body
{
    "title": "Quadratic formula",
    "content": "x = (-b \u00b1 \u221a(b\u00b2-4ac)) / 2a",
    "category": "Formula"
}
Response 201 OK
{
    "data": {
        "id": 12,
        "title": "Quadratic formula",
        "content": "x = (-b \u00b1 \u221a(b\u00b2-4ac)) / 2a",
        "category": "Formula"
    }
}
PUT /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.

Request body
{
    "title": "Quadratic formula (updated)",
    "content": "...",
    "category": "Formula"
}
Response 200 OK
{
    "data": {
        "id": 12,
        "title": "Quadratic formula (updated)"
    }
}
DELETE /api/v1/notes/{note} Auth required

Delete one of the authenticated user's own notes.

403 if it belongs to a different user.

Response 200 OK
{
    "data": {
        "message": "Note deleted."
    }
}

Leaderboard

Real aggregate ranking over completed exam sessions — not a demo/mock list.

GET /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.

Response 200 OK
{
    "data": [
        {
            "id": 42,
            "name": "Ada Obi",
            "avatar_url": null,
            "exam_type": "JAMB",
            "best_score": 96,
            "average_score": 88.4,
            "exams_count": 12
        }
    ]
}

Questions

The hydration half of the bookmarks "IDs first, then hydrate" pattern.

GET /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.

Response 200 OK
{
    "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-\"."
        }
    ]
}

Delta Sync

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.

GET /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.

Response 200 OK
{
    "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"
}

Exam Setup & Session

The core exam-taking lifecycle: pick a configuration, take the exam, pause and resume, or walk away.

GET /api/v1/exams/{exam} Auth required

Fetch a published exam blueprint.

Draft blueprints return 403. `{exam}` is the blueprint ID.

Response 200 OK
{
    "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
    }
}
POST /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.

Request body
{
    "exam_type_id": 1,
    "subject_ids": [
        3,
        5
    ],
    "duration": 60,
    "mode": "practice",
    "shuffle_questions": true,
    "shuffle_options": false,
    "exam_id": null
}
Response 201 OK
{
    "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"
    }
}
GET /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.

Response 200 OK
{
    "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"
        }
    }
}
GET /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.

Response 200 OK
{
    "data": {
        "status": "in_progress",
        "extra_time": 0
    }
}
PATCH /api/v1/exams/{session}/pause Auth required

Pause an in-progress session.

Records the client-reported time remaining.

Request body
{
    "time_remaining": 2140
}
Response 200 OK
{
    "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"
    }
}
PATCH /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.

Response 200 OK
{
    "data": {
        "time_remaining": 2140
    }
}
POST /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."

Request body
{
    "type": "tab_switch",
    "count": 2
}
Response 200 OK
{
    "data": {
        "message": "Violation logged."
    }
}
POST /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.

Request body
{
    "answers": [
        {
            "question_id": 501,
            "selected_answer": "B"
        },
        {
            "question_id": 733,
            "selected_answer": null
        }
    ]
}
Response 200 OK
{
    "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
    }
}
GET /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.

Response 200 OK
{
    "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
            }
        ]
    }
}
DELETE /api/v1/exams/{session} Auth required

Discard an abandoned session.

Only while in progress or paused.

Response 200 OK
{
    "data": {
        "message": "Session discarded."
    }
}
GET /api/v1/exams/history Auth required

List the authenticated user's past exam sessions.

Response 200 OK
{
    "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"
        }
    ]
}
GET /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.

Response 200 OK
{
    "data": {
        "topic_stats": [
            {
                "topic": "Organic Chemistry",
                "correct": 28,
                "total": 40,
                "percentage": 70
            },
            {
                "topic": "Atomic Structure",
                "correct": 11,
                "total": 40,
                "percentage": 27.5
            }
        ]
    }
}

License Activation

How a student unlocks Pro — a redeemable key, a mock local flow, or a real Paystack payment.

POST /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.

Request body
{
    "activation_key": "APC-7F3K-2QRT-88ZN",
    "machine_id": "DESKTOP-7F3K2Q"
}
Response 200 OK
{
    "data": {
        "message": "Pro access activated.",
        "is_pro": true
    }
}
POST /api/v1/license/paystack/initialize Auth required

Start a real Paystack payment for a Pro key.

Request body
{
    "callback_url": "https://app.example.com/payment/callback"
}
Response 200 OK
{
    "data": {
        "authorization_url": "https://checkout.paystack.com/abc123",
        "access_code": "abc123",
        "reference": "apc_ref_9f2c1a"
    }
}
POST /api/v1/license/paystack-mock Auth required

Simulate a successful payment without a real charge.

Local development only.

Request body
{
    "reference": "apc_ref_9f2c1a",
    "machine_id": "DESKTOP-7F3K2Q"
}
Response 200 OK
{
    "data": {
        "message": "Pro access activated.",
        "is_pro": true
    }
}
POST /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.

Response 200 OK
{
    "data": {
        "message": "Webhook processed."
    }
}

Admin — Dashboard

Platform-wide numbers for the admin overview screen.

GET /api/v1/admin/stats Admin only

Overview, score distribution, exam-type popularity, and recent activity.

Query: `?range=7d|30d|90d`. Cached briefly per range.

Response 200 OK
{
    "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
            }
        ]
    }
}
GET /api/v1/admin/stats/export Admin only

Export the same overview as CSV, XLSX, or PDF.

Query: `?format=csv|xlsx|pdf&range=30d`.

Admin — Exam Types

The exam-type catalog (JAMB, WAEC, …) students choose from at setup.

GET /api/v1/admin/exam-types Admin only

List exam types with session counts.

Response 200 OK
{
    "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"
        }
    ]
}
POST /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.

Request body
{
    "code": "GCE",
    "title": "GCE O/Level",
    "description": "General Certificate of Education.",
    "default_duration": 90,
    "color": "teal",
    "icon": "BookOpen",
    "allowed_modes": [
        "practice",
        "exam"
    ],
    "enabled": true
}
Response 201 OK
{
    "data": {
        "id": 6,
        "code": "GCE",
        "title": "GCE O/Level",
        "icon": "BookOpen",
        "enabled": true,
        "order": 6
    }
}
GET /api/v1/admin/exam-types/{exam_type} Admin only

Fetch one exam type.

Response 200 OK
{
    "data": {
        "id": 1,
        "code": "JAMB",
        "title": "JAMB / UTME"
    }
}
PATCH /api/v1/admin/exam-types/{exam_type} Admin only

Update an exam type.

`code` cannot be changed — omit it, it is ignored if sent.

Request body
{
    "title": "JAMB / UTME",
    "description": "Updated description.",
    "default_duration": 150,
    "color": "indigo",
    "icon": "GraduationCap",
    "allowed_modes": [
        "practice",
        "exam"
    ],
    "enabled": true
}
Response 200 OK
{
    "data": {
        "id": 1,
        "title": "JAMB / UTME",
        "default_duration": 150,
        "icon": "GraduationCap"
    }
}
DELETE /api/v1/admin/exam-types/{exam_type} Admin only

Delete an exam type.

Blocked (409) while it still has sessions or questions attached.

Response 200 OK
{
    "data": {
        "message": "Exam type deleted."
    }
}
POST /api/v1/admin/exam-types/reorder Admin only

Reorder the exam-type list.

Atomic — a partial list leaves untouched rows' order alone.

Request body
{
    "ids": [
        3,
        1,
        2,
        5,
        4
    ]
}
Response 200 OK
{
    "data": {
        "message": "Order updated."
    }
}
POST /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.

Request body
{
    "image": "(binary file)"
}
Response 200 OK
{
    "data": {
        "image_url": "https://api.example.com/storage/exam-type-cards/1.png"
    }
}
PATCH /api/v1/admin/exam-types/{examType}/toggle Admin only

Flip an exam type enabled/disabled.

No request body.

Response 200 OK
{
    "data": {
        "id": 1,
        "enabled": false
    }
}
GET /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.

Response 200 OK
{
    "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
            }
        ]
    }
}

Admin — Subjects

Subjects and their topics, pivoted per exam type.

GET /api/v1/admin/subjects Admin only

List and search subjects.

Query: `?search=chem&examType=JAMB`.

Response 200 OK
{
    "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"
        }
    ]
}
POST /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.

Request body
{
    "name": "Chemistry",
    "code": "CHM",
    "exam_type_ids": [
        1,
        2
    ],
    "description": "Physical & organic chemistry",
    "icon": "FlaskConical",
    "color": "#0EA5E9",
    "topics": [
        "Atomic Structure",
        "Organic Chemistry"
    ]
}
Response 201 OK
{
    "data": {
        "id": 3,
        "name": "Chemistry",
        "code": "CHM",
        "icon": "FlaskConical",
        "color": "#0EA5E9",
        "topics": [
            "Atomic Structure",
            "Organic Chemistry"
        ]
    }
}
GET /api/v1/admin/subjects/{subject} Admin only

Fetch one subject.

Response 200 OK
{
    "data": {
        "id": 3,
        "name": "Chemistry",
        "code": "CHM"
    }
}
PATCH /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.

Request body
{
    "name": "Chemistry",
    "code": "CHM",
    "exam_type_ids": [
        1,
        2
    ],
    "icon": "FlaskConical",
    "color": "#0EA5E9",
    "topics": [
        "Atomic Structure",
        "Organic Chemistry",
        "Electrochemistry"
    ]
}
Response 200 OK
{
    "data": {
        "id": 3,
        "name": "Chemistry",
        "topics": [
            "Atomic Structure",
            "Organic Chemistry",
            "Electrochemistry"
        ]
    }
}
DELETE /api/v1/admin/subjects/{subject} Admin only

Delete a subject.

Rejected (400) while it still has questions attached.

Response 200 OK
{
    "data": {
        "message": "Subject deleted."
    }
}
DELETE /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`.

Response 200 OK
{
    "data": {
        "deleted": [
            4,
            9
        ],
        "skipped": [
            {
                "id": 3,
                "reason": "Has 420 questions attached."
            }
        ]
    }
}
POST /api/v1/admin/subjects/reorder Admin only

Reorder subjects.

Request body
{
    "ids": [
        5,
        3,
        1
    ]
}
Response 200 OK
{
    "data": {
        "message": "Order updated."
    }
}
GET /api/v1/admin/subjects/export Admin only

Export subjects as CSV, XLSX, or PDF.

Query: `?format=csv|xlsx|pdf`.

POST /api/v1/admin/subjects/import Admin only

Preview a subject CSV/XLSX import before committing.

multipart/form-data, field `file`.

Request body
{
    "file": "(binary file)"
}
Response 200 OK
{
    "data": {
        "valid_rows": 18,
        "invalid_rows": [
            {
                "row": 4,
                "errors": {
                    "code": [
                        "The code has already been taken."
                    ]
                }
            }
        ],
        "token": "import_9f2c1a"
    }
}
POST /api/v1/admin/subjects/import/commit Admin only

Commit a previously previewed subject import.

Re-validates every row at commit time.

Request body
{
    "token": "import_9f2c1a"
}
Response 200 OK
{
    "data": {
        "created": 17,
        "skipped": 1
    }
}

Admin — Questions Bank

The question content itself — always scoped to one exam type at a time.

GET /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.

Response 200 OK
{
    "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
        }
    ]
}
POST /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`.

Request body
{
    "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..."
}
Response 201 OK
{
    "data": {
        "id": 501,
        "type": "mcq",
        "content": "..."
    }
}
GET /api/v1/admin/questions/{question} Admin only

Fetch one question.

Response 200 OK
{
    "data": {
        "id": 501,
        "type": "mcq"
    }
}
PATCH /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.

Request body
{
    "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"
}
Response 200 OK
{
    "data": {
        "id": 501,
        "difficulty": "hard"
    }
}
DELETE /api/v1/admin/questions/{question} Admin only

Delete a question.

Its recorded answers are deleted with it.

Response 200 OK
{
    "data": {
        "message": "Question deleted."
    }
}
PATCH /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.

Request body
{
    "ids": [
        501,
        502,
        503
    ],
    "difficulty": "hard"
}
Response 200 OK
{
    "data": {
        "updated": 3
    }
}
DELETE /api/v1/admin/questions Admin only

Bulk-delete questions by ID.

Query: `?ids=501,502`.

Response 200 OK
{
    "data": {
        "deleted": 2
    }
}
GET /api/v1/admin/questions/export Admin only

Export questions as CSV, XLSX, or answer-free PDF.

Query: `?format=csv|xlsx|pdf&showAnswers=0|1`.

POST /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.

Request body
{
    "file": "(binary file)"
}
Response 200 OK
{
    "data": {
        "valid_rows": 40,
        "invalid_rows": [],
        "token": "import_a1b2c3"
    }
}
POST /api/v1/admin/questions/import/commit Admin only

Commit a previously previewed question import.

Request body
{
    "token": "import_a1b2c3"
}
Response 200 OK
{
    "data": {
        "created": 40
    }
}

Admin — Exams (Blueprints)

Pre-configured exam templates a student can launch by ID instead of configuring one manually.

GET /api/v1/admin/exams Admin only

List exam blueprints.

Response 200 OK
{
    "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"
        }
    ]
}
POST /api/v1/admin/exams Admin only

Create a blueprint.

Mode and subjects are cross-checked against the exam type's own allowances.

Request body
{
    "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"
}
Response 201 OK
{
    "data": {
        "id": 7,
        "status": "draft"
    }
}
GET /api/v1/admin/exams/{exam} Admin only

Fetch one blueprint.

Response 200 OK
{
    "data": {
        "id": 7,
        "status": "draft"
    }
}
PATCH /api/v1/admin/exams/{exam} Admin only

Update a blueprint.

Request body
{
    "exam_type_id": 1,
    "duration": 120,
    "subject_ids": [
        3,
        5,
        8,
        11
    ],
    "mode": "exam",
    "status": "published"
}
Response 200 OK
{
    "data": {
        "id": 7,
        "status": "published"
    }
}
DELETE /api/v1/admin/exams/{exam} Admin only

Delete a blueprint.

Sessions already started from it keep their own copy of the configuration.

Response 200 OK
{
    "data": {
        "message": "Exam blueprint deleted."
    }
}
PATCH /api/v1/admin/exams/bulk-status Admin only

Bulk publish or unpublish blueprints.

Request body
{
    "ids": [
        7,
        8
    ],
    "status": "published"
}
Response 200 OK
{
    "data": {
        "updated": 2
    }
}
DELETE /api/v1/admin/exams Admin only

Bulk-delete blueprints by ID.

Query: `?ids=7,8`.

Response 200 OK
{
    "data": {
        "deleted": 2
    }
}
GET /api/v1/admin/exams/export Admin only

Export blueprints as CSV, XLSX, or PDF.

Query: `?format=csv|xlsx|pdf`.

POST /api/v1/admin/exams/import Admin only

Preview a blueprint CSV/XLSX import.

multipart/form-data, field `file`.

Request body
{
    "file": "(binary file)"
}
Response 200 OK
{
    "data": {
        "valid_rows": 5,
        "invalid_rows": [],
        "token": "import_e5f6g7"
    }
}
POST /api/v1/admin/exams/import/commit Admin only

Commit a previously previewed blueprint import.

Request body
{
    "token": "import_e5f6g7"
}
Response 200 OK
{
    "data": {
        "created": 5
    }
}

Admin — Users

Every registered account, student and admin alike.

GET /api/v1/admin/users Admin only

List and search users, with session and question-answered counts.

Query: `?search=ada&role=student&examType=JAMB`.

Response 200 OK
{
    "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"
        }
    ]
}
POST /api/v1/admin/users Admin only

Create a user directly (no self-registration flow).

Request body
{
    "name": "Chinedu Okoye",
    "email": "chinedu@example.com",
    "role": "student",
    "exam_type": "WAEC",
    "phone": null,
    "password": "secret123"
}
Response 201 OK
{
    "data": {
        "id": 88,
        "name": "Chinedu Okoye",
        "role": "student"
    }
}
GET /api/v1/admin/users/{user} Admin only

Fetch one user's detail, including their recent sessions.

Response 200 OK
{
    "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"
            }
        ]
    }
}
PATCH /api/v1/admin/users/{user} Admin only

Update a user, including role.

Leaving `password` blank keeps the existing one.

Request body
{
    "name": "Ada Obi",
    "email": "ada@example.com",
    "role": "admin",
    "exam_type": "JAMB",
    "phone": null
}
Response 200 OK
{
    "data": {
        "id": 42,
        "role": "admin"
    }
}
DELETE /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.

Response 200 OK
{
    "data": {
        "message": "User deleted."
    }
}
PATCH /api/v1/admin/users/bulk-role Admin only

Bulk-change role for a set of users.

Same zero-admins guard as single delete.

Request body
{
    "ids": [
        42,
        88
    ],
    "role": "student"
}
Response 200 OK
{
    "data": {
        "updated": 2
    }
}
DELETE /api/v1/admin/users Admin only

Bulk-delete users by ID.

Query: `?ids=42,88`.

Response 200 OK
{
    "data": {
        "deleted": 2
    }
}
POST /api/v1/admin/users/import Admin only

Preview a user CSV/XLSX import.

multipart/form-data, field `file`.

Request body
{
    "file": "(binary file)"
}
Response 200 OK
{
    "data": {
        "valid_rows": 30,
        "invalid_rows": [],
        "token": "import_h8i9j0"
    }
}
POST /api/v1/admin/users/import/commit Admin only

Commit a previously previewed user import.

Request body
{
    "token": "import_h8i9j0"
}
Response 200 OK
{
    "data": {
        "created": 30
    }
}

Admin — Sessions

A read/audit view over every exam session, plus the two interventions an admin can make on a live one.

GET /api/v1/admin/sessions Admin only

List and filter exam sessions across every student.

Query: `?status=in_progress&minScore=50&startedFrom=2026-07-01`.

Response 200 OK
{
    "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"
        }
    ]
}
GET /api/v1/admin/sessions/{session} Admin only

Fetch one session's full detail.

Response 200 OK
{
    "data": {
        "id": 981,
        "status": "completed",
        "percentage": 80
    }
}
DELETE /api/v1/admin/sessions/{session} Admin only

Delete a session and its recorded answers.

Response 200 OK
{
    "data": {
        "message": "Session deleted."
    }
}
DELETE /api/v1/admin/sessions Admin only

Bulk-delete sessions by ID.

Query: `?ids=981,982`.

Response 200 OK
{
    "data": {
        "deleted": 2
    }
}
GET /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.

POST /api/v1/admin/sessions/{session}/extra-time Admin only

Grant extra time to a session in progress.

Request body
{
    "minutes": 15
}
Response 200 OK
{
    "data": {
        "id": 981,
        "extra_time": 900
    }
}
POST /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.

Response 200 OK
{
    "data": {
        "session": {
            "id": 981,
            "status": "completed",
            "percentage": 62.5
        },
        "subject_results": [],
        "answers_submitted": 20
    }
}

Admin — Notes / Bookmarks / Leaderboard Monitoring

Read-only visibility into student-generated activity that has no other admin surface — what they write down, what they save, and how they rank.

GET /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`.

Response 200 OK
{
    "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"
        }
    ]
}
GET /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`.

Response 200 OK
{
    "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"
        }
    ]
}
GET /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).

Response 200 OK
{
    "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
        }
    ]
}

Admin — Error Reports

Question-quality issues flagged by students, snapshotted so later question edits can't rewrite history.

GET /api/v1/admin/error-reports Admin only

List and filter error reports.

Query: `?status=open`.

Response 200 OK
{
    "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
        }
    ]
}
GET /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.

Response 200 OK
{
    "data": {
        "id": 12,
        "status": "open"
    }
}
DELETE /api/v1/admin/error-reports/{error_report} Admin only

Delete a report.

Response 200 OK
{
    "data": {
        "message": "Report deleted."
    }
}
PATCH /api/v1/admin/error-reports/{errorReport} Admin only

Mark a report resolved or reopen it.

Request body
{
    "status": "resolved"
}
Response 200 OK
{
    "data": {
        "id": 12,
        "status": "resolved",
        "resolved_at": "2026-07-20T09:00:00Z"
    }
}
PATCH /api/v1/admin/error-reports/bulk-status Admin only

Bulk-update status for a set of reports.

Request body
{
    "ids": [
        12,
        13
    ],
    "status": "resolved"
}
Response 200 OK
{
    "data": {
        "updated": 2
    }
}
DELETE /api/v1/admin/error-reports Admin only

Bulk-delete reports by ID.

Query: `?ids=12,13`.

Response 200 OK
{
    "data": {
        "deleted": 2
    }
}
GET /api/v1/admin/error-reports/export Admin only

Export reports as CSV, XLSX, or PDF.

Query: `?format=csv|xlsx|pdf`.

Admin — License Keys

Pro access keys — generated in bulk, printed as scratch cards, and revocable.

GET /api/v1/admin/license-keys Admin only

List and filter license keys, with activation history.

Query: `?status=unused&type=single`.

Response 200 OK
{
    "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"
        }
    ]
}
POST /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.

Request body
{
    "quantity": 50,
    "max_uses": 1
}
Response 201 OK
{
    "data": {
        "generated": 50,
        "keys": [
            "APC-7F3K-2QRT-88ZN",
            "..."
        ]
    }
}
GET /api/v1/admin/license-keys/{license_key} Admin only

Fetch one key's detail and activation history.

Response 200 OK
{
    "data": {
        "id": 5,
        "code": "APC-7F3K-2QRT-88ZN",
        "activations": [
            {
                "user": {
                    "name": "Ada Obi"
                },
                "machine_id": "DESKTOP-7F3K2Q",
                "activated_at": "2026-07-05T10:00:00Z"
            }
        ]
    }
}
DELETE /api/v1/admin/license-keys/{license_key} Admin only

Revoke a key.

Never retroactively removes Pro access already granted from it.

Response 200 OK
{
    "data": {
        "message": "License key revoked."
    }
}
GET /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).

Admin — Settings

Whole-section platform configuration — branding, registration rules, Pro pricing, email, footer, landing page copy — plus asset uploads for logo/favicon/hero images.

GET /api/v1/admin/settings/{section} Admin only

Fetch one settings section.

Section is one of: general, registration, pro, email, footer, system, landing_page.

Response 200 OK
{
    "data": {
        "platform_name": "APC CBT Practice",
        "support_email": "support@cbtpractice.com",
        "tagline": "Practice smarter, score higher.",
        "logo_url": "",
        "favicon_url": "",
        "primary_color": "#027a48"
    }
}
PUT /api/v1/admin/settings/{section} Admin only

Replace a settings section.

Whole-section replace — omitted fields revert to platform defaults, they do not merge.

Request body
{
    "values": {
        "platform_name": "APC CBT Practice",
        "support_email": "help@cbtpractice.com",
        "tagline": "Practice smarter, score higher.",
        "primary_color": "#027a48"
    }
}
Response 200 OK
{
    "data": {
        "platform_name": "APC CBT Practice",
        "support_email": "help@cbtpractice.com"
    }
}
POST /api/v1/admin/settings/{section}/reset Admin only

Reset one section back to platform defaults.

No request body.

Response 200 OK
{
    "data": {
        "platform_name": "APC CBT Practice",
        "tagline": "Practice smarter, score higher."
    }
}
POST /api/v1/admin/settings/email/test Admin only

Send a real test email using the saved SMTP settings.

No request body.

Response 200 OK
{
    "data": {
        "message": "Test email sent."
    }
}
POST /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`).

Request body
{
    "file": "(binary \u2014 multipart upload)",
    "type": "logo"
}
Response 200 OK
{
    "data": {
        "url": "http://127.0.0.1:8000/storage/settings-assets/logo/0ba66691-610d-4690-a9d7-dfc16198161b.png"
    }
}

Admin — Content Upload

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.

POST /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.

Request body
{
    "file": "(binary \u2014 multipart upload)"
}
Response 200 OK
{
    "data": {
        "url": "http://127.0.0.1:8000/storage/content-uploads/2f1a9c3e-....png"
    }
}

No endpoints match that filter.