API CONTRACT
Versioning
All endpoints below are namespaced under /api/v1/. For example, GET /api/schools/
in this document means GET /api/v1/schools/. Breaking changes will ship under a new
version prefix (/api/v2/); /api/v1/ will be maintained for a documented
deprecation window once a v2 exists.
Authentication Modes
- User auth - JWT bearer token, obtained via the Google login flow below. Used by the frontend and any user-driven action.
- API client auth (Future) -
X-API-Keyheader, used by registered third-party consumers hitting public read-only endpoints. Not available in MVP.
Health Check
Health Check
Endpoint: GET /api/v1/health/
Response (200 OK):
{
"status": "ok",
"service": "academia-backend",
"checks": {
"database": "ok",
"cache": "ok"
},
"timestamp": "2026-01-01T00:00:00Z"
}
No authentication required. status is "degraded" if the database is
unreachable, a cache outage alone does not count as degraded, caching is
designed to fail open (see project-plan.md). Useful for uptime monitoring and
load balancer health checks, not intended for frontend application logic.
Authentication
How Google Auth Works (Frontend Implementation Guide)
This backend does not use Django's full server-side social-auth flow. Instead,
the frontend obtains a Google access token directly using Google Identity
Services, then hands that token to our backend, which verifies it by calling
Google's own userinfo endpoint. This means: no client secret on the backend, no
redirect-based OAuth dance, and a small, predictable request/response contract.
One-time setup (Google Cloud Console):
1. Create a project (or use an existing one) at console.cloud.google.com.
2. Configure the OAuth consent screen (External, testing or published as needed).
3. Create an OAuth 2.0 Client ID of type Web application.
4. Add your frontend's origin(s) under Authorized JavaScript origins
(e.g. http://localhost:3000, and your production domain later).
5. You only need the Client ID on the frontend, no client secret is used
anywhere in this flow.
Frontend flow, step by step:
1. Load Google's Identity Services script:
<script src="https://accounts.google.com/gsi/client" async defer></script>
2. Initialize a token client with your Client ID and the scopes
email profile:
const client = google.accounts.oauth2.initTokenClient({
client_id: "YOUR_CLIENT_ID.apps.googleusercontent.com",
scope: "email profile",
callback: (tokenResponse) => {
// tokenResponse.access_token is what you send to our backend
},
});
- On your "Sign in with Google" button click, call
client.requestAccessToken(). This opens Google's consent popup and returns anaccess_tokenin the callback. - POST that token to our backend:
POST /api/v1/auth/google/with body{ "access_token": "<token_from_step_3>" }. - Our backend verifies the token against Google, creates the user if this is their
first login, and returns our own JWT pair (
access/refresh) plus the user object, see the endpoint spec below. - Store the JWT pair (httpOnly cookie is preferred once you're past local dev,
in-memory/localStorage is fine for early development) and attach
Authorization: Bearer <access>to every subsequent authenticated request. - When a request comes back
401because the access token expired, callPOST /api/v1/auth/refresh/with the stored refresh token to get a new access token, see below. Note refresh tokens rotate on use, so store the new one returned each time. - On logout, POST the current refresh token to
/api/v1/auth/logout/(this blacklists it server-side) and clear locally stored tokens.
This is the entire flow, there's no separate "callback URL" or server-rendered redirect page to build, it's a client-side popup plus one API call.
Login and token refresh are both rate limited to 10 requests per minute per client (see Rate Limits below), to slow down credential-stuffing style abuse.
Google Login
Endpoint: POST /api/v1/auth/google/
Request:
{
"access_token": "google_oauth_access_token"
}
Response (200 OK):
{
"access": "jwt_access_token",
"refresh": "jwt_refresh_token",
"user": {
"id": "uuid",
"email": "user@example.com",
"full_name": "John Doe",
"avatar": "https://lh3.googleusercontent.com/..."
}
}
Response (400 Bad Request):
{
"error": "access_token is required"
}
Response (401 Unauthorized):
{
"error": "Invalid Google token"
}
Behavior notes: first login for an email creates the User record automatically
(no separate signup step); subsequent logins reuse the same user and refresh
full_name/avatar from Google if they've changed.
Refresh Token
Endpoint: POST /api/v1/auth/refresh/
Request:
{
"refresh": "jwt_refresh_token"
}
Response (200 OK):
{
"access": "new_jwt_access_token",
"refresh": "new_jwt_refresh_token"
}
Response (400 Bad Request):
{
"refresh": ["This field is required."]
}
Response (401 Unauthorized):
{
"error": "Token is invalid or expired"
}
Refresh tokens rotate on every use and the previous one is blacklisted, always store whichever refresh token was most recently issued.
Logout
Endpoint: POST /api/v1/auth/logout/
Request:
{
"refresh": "jwt_refresh_token"
}
Response (204 No Content): Empty
Response (400 Bad Request):
{
"error": "refresh token is required"
}
or
{
"error": "Invalid or already blacklisted token"
}
Response (401 Unauthorized):
{
"error": "Authentication credentials were not provided."
}
Blacklists the given refresh token server-side, it can no longer be used to obtain new access tokens.
Users
Get Current User
Endpoint: GET /api/v1/users/me/
Response (200 OK):
{
"id": "uuid",
"email": "user@example.com",
"full_name": "John Doe",
"avatar": "https://lh3.googleusercontent.com/...",
"is_admin": false,
"moderator_for": [
{
"hub_id": "uuid",
"school": { "id": "uuid", "name": "University of Lagos", "slug": "unilag" }
}
],
"representative_for": [
{
"hub_id": "uuid",
"school": { "id": "uuid", "name": "University of Lagos", "slug": "unilag" }
}
],
"stats": {
"question_count": 12,
"answer_count": 34,
"best_answer_count": 9,
"comment_count": 21
},
"created_at": "2026-01-01T00:00:00Z"
}
moderator_for and representative_for list every hub (with its school) where
this user currently holds an active assignment. Both are empty arrays for a
user with no such assignments. stats is a count-only summary for this user's
own activity (own question/answer/comment counts, plus how many of their
answers are marked best), intended for badge-style display (e.g. "Posts (3)"),
the actual objects are fetched via the endpoints below, not embedded here.
This same shape, including stats, is also returned in the user field of the
Google Login response. See "Frontend Permission Model" below for how to use
the role fields.
Update Current User
Endpoint: PATCH /api/v1/users/me/
Request:
{
"full_name": "Johnathan Doe"
}
Response (200 OK): Same as GET /api/v1/users/me/
Search Users (Admin or School Representative)
Endpoint: GET /api/v1/users/search/
Query Parameters:
- search - Search by email or full_name, required, empty results if omitted
Response (200 OK):
{
"results": [
{
"id": "uuid",
"full_name": "Jane Smith",
"email": "jane@example.com",
"avatar": "https://..."
}
]
}
Error Response (403 Forbidden):
{
"error": "You do not have permission to search for users"
}
Available to platform admins and to any user with an active SchoolRepresentativeAssignment for at least one hub, not scoped to a specific hub, since the assignment endpoints themselves already enforce hub-specific permission at the point of use. This exists specifically so a School Representative can find a user_id to pass into POST /hubs/{hub_id}/moderators/ without needing admin access. Not paginated, results capped at 10, matching the existing Tags search-as-you-type pattern.
Frontend Permission Model
The API enforces every permission independently, server-side, on every write. The fields described here exist purely so the frontend can show or hide the right links, buttons, and forms without guessing, they are a UX convenience, not a substitute for backend enforcement, hiding a button never replaces the 403 a user would get if they attempted the action directly.
Roles
- User: any authenticated account. Can ask/answer/comment/vote/report/ request a hub.
- Moderator: a User with an active entry in that hub's
moderator_for. Can additionally see that hub's unanswered-questions queue. - School Representative: a User with an active entry in that hub's
representative_for. Can additionally manage departments for that hub's school, and assign/remove moderators for that hub. - Admin:
is_admin: true. Implicitly satisfies every Moderator and Representative check for every hub, in addition to admin-only actions (approve/reject hub activations, manage schools, assign representatives, resolve reports, manage users). An admin'smoderator_for/representative_forarrays are not artificially populated with every hub,is_adminalone is the signal to check for blanket access.
How a user gets a role
There is no self-service way to become a Moderator or Representative. An
admin assigns both explicitly:
- POST /hubs/{hub_id}/representatives/ (admin only) makes a user a
Representative for that hub.
- POST /hubs/{hub_id}/moderators/ (admin, or an existing Representative for
that hub) makes a user a Moderator for that hub.
The newly assigned user does not see this reflected until their next
GET /users/me/ call picks up the new moderator_for/representative_for
entry, there is currently no push notification for this (MODERATOR_ASSIGNED
exists as a notification type but has no trigger wired to it yet, tracked in
feature-list.md). Refetch the current user after login and when navigating
into a school or hub the person might administer, rather than relying on it
updating automatically mid-session.
Deriving what to show
Given the current user's moderator_for/representative_for/is_admin, and
a specific school or hub the person is viewing:
| Show this... | ...when |
|---|---|
| "Ask a question" | any authenticated user |
| "Request hub activation" | any authenticated user, on a school with has_hub: false |
| "Manage departments" (create/edit) for a school | is_admin is true, OR that school's id appears in representative_for[].school.id |
| "Assign / remove moderators" for a hub | is_admin is true, OR that hub's id appears in representative_for[].hub_id |
| "Assign / remove representatives" for a hub | is_admin is true only |
| "Unanswered questions" queue link, scoped to a hub | is_admin is true, OR that hub's id appears in moderator_for[].hub_id |
| Admin dashboard (schools, activation requests, reports, users) | is_admin is true only |
Matching against representative_for[].school.id directly (rather than
resolving a hub id first) is the fastest way to check department-management
access for a school page, since the school's own id is already on hand
wherever a school is being displayed.
Schools
List Schools
Endpoint: GET /api/v1/schools/
Query Parameters:
- search - Search by name or short_name
- has_hub - Filter schools with/without hub (true/false)
- institution_type - Filter by UNIVERSITY/POLYTECHNIC/COLLEGE_OF_EDUCATION
- ownership - Filter by FEDERAL/STATE/PRIVATE
- state - Filter by Nigerian state (exact match, case-insensitive)
- page - Page number (default: 1)
- page_size - Items per page (default: 20, max: 100)
Response (200 OK):
{
"count": 100,
"next": "https://api.academia.com/api/v1/schools/?page=2",
"previous": null,
"results": [
{
"id": "uuid",
"name": "University of Lagos",
"short_name": "UNILAG",
"slug": "unilag",
"location": "Lagos, Nigeria",
"website": "https://unilag.edu.ng",
"has_hub": true,
"is_active": true,
"institution_type": "UNIVERSITY",
"ownership": "FEDERAL",
"state": "Lagos",
"country": "Nigeria",
"created_at": "2026-01-01T00:00:00Z"
}
]
}
Caching & Visibility Behavior:
- Cache: Public requests are cached with prefix school-list:query using CACHE_TTL_SHORT (60 seconds).
- Admin Bypass: Authenticated Platform Admins bypass the cache completely (to see live entries and inactive/deactivated schools, which are filtered out for regular users/guests).
- Invalidation: Wiped automatically on POST school creation or PATCH school updates.
All three new filters combine with AND when used together, and with the
existing search/has_hub filters. institution_type and ownership
values are case-insensitive on input (uppercased before matching), state
matches case-insensitively against the stored value.
Get School
Endpoint: GET /api/v1/schools/{school_id}/
Response (200 OK):
{
"id": "uuid",
"name": "University of Lagos",
"short_name": "UNILAG",
"slug": "unilag",
"location": "Lagos, Nigeria",
"website": "https://unilag.edu.ng",
"has_hub": true,
"is_active": true,
"verification_status": "VERIFIED",
"institution_type": "UNIVERSITY",
"ownership": "FEDERAL",
"state": "Lagos",
"country": "Nigeria",
"regulatory_code": null,
"source_url": null,
"last_verified_at": null,
"departments": [
{
"id": "uuid",
"name": "Computer Science",
"code": "CSC",
"question_count": 42,
"is_active": true
}
],
"created_at": "2026-01-01T00:00:00Z"
}
Response (404 Not Found):
{
"error": "School not found"
}
Caching & Visibility Behavior:
- Cache: Public detail views are cached as school-detail:{school_id} using CACHE_TTL_MEDIUM (300 seconds).
- Admin Bypass: Authenticated Platform Admins bypass this cache entirely and view live data (including inactive status).
- Invalidation: Cleared on PATCH school update of the school, or POST/PATCH operations on the school's departments.
regulatory_code, source_url, and last_verified_at are null for every
school until the NUC/NBTE/NCCE directory import lands and/or an admin sets
them manually. institution_type, ownership, and state are also
nullable, expect null on any school created before that import.
Get School by Slug
Endpoint: GET /api/v1/schools/by-slug/{slug}/
Response (200 OK): Same as GET /api/v1/schools/{school_id}/
Response (404 Not Found):
{
"error": "School not found"
}
Caching Behavior:
- Cache: Cached as school-by-slug:{slug} using CACHE_TTL_MEDIUM (300 seconds). No admin bypass on this slug lookup path.
This is an additive lookup path alongside the existing UUID-based one, both remain
valid indefinitely. slug is generated once at creation from short_name and never
regenerated, so a bookmarked or shared URL never breaks even if short_name changes
later.
Create School (Admin Only)
Endpoint: POST /api/v1/schools/
Request:
{
"name": "University of Ibadan",
"short_name": "UI",
"location": "Ibadan, Nigeria",
"website": "https://ui.edu.ng",
"institution_type": "UNIVERSITY",
"ownership": "FEDERAL",
"state": "Oyo",
"country": "Nigeria",
"regulatory_code": null,
"source_url": null,
"last_verified_at": null
}
short_name is automatically uppercased on save. location, website, and
every field from institution_type through last_verified_at are optional.
A slug is auto-generated from short_name (e.g. "UI" → "ui") and
is never regenerated. country defaults to "Nigeria" if omitted.
Response (201 Created): Same as GET /api/v1/schools/{school_id}/
Error Responses:
// 400 Bad Request - Duplicate school name or short_name
{
"name": ["school with this name already exists."],
"short_name": ["school with this short name already exists."]
}
// 400 Bad Request - Duplicate regulatory_code
{
"regulatory_code": ["school with this regulatory code already exists."]
}
Invalidation Note: Invalidates and clears all entries matching the school-list cache prefix.
Update School (Admin Only)
Endpoint: PATCH /api/v1/schools/{school_id}/
Request: Same fields as POST (see Create School above), all optional. Accepts
"is_active": true/false. This is also how regulatory_code, source_url, and
last_verified_at get set or corrected after the fact, whether by the import
process or by an admin.
Response (200 OK): Same as GET /api/v1/schools/{school_id}/
Invalidation Note: Invalidates school-detail:{school_id} cache key and the school-list cache prefix.
Delete School (Admin Only)
There is no dedicated DELETE endpoint. "Deleting" a school is a soft-delete,
send PATCH /api/v1/schools/{school_id}/ with {"is_active": false}. This keeps
foreign-key relationships (hubs, questions, etc.) intact rather than orphaning
them, and is easily reversible. A deactivated school is excluded from list/detail
responses for non-admins.
Hubs
Get Hub
Endpoint: GET /api/v1/hubs/{hub_id}/
Response (200 OK):
{
"id": "uuid",
"school": {
"id": "uuid",
"name": "University of Lagos",
"short_name": "UNILAG",
"slug": "unilag",
"location": "Lagos, Nigeria"
},
"is_active": true,
"activated_at": "2026-01-01T00:00:00Z",
"departments": [
{
"id": "uuid",
"name": "Computer Science",
"code": "CSC",
"question_count": 42
}
],
"question_count": 150,
"moderator_count": 3
}
Response (404 Not Found):
{
"error": "Hub not found"
}
Caching & Visibility Behavior:
- Cache: Cached as hub-detail:{hub_id} using CACHE_TTL_SHORT (60 seconds).
- Invalidation: Cleared automatically on:
- Approve Activation Request (hub activation).
- Assign / Remove Moderator.
Get Hub by School
Endpoint: GET /api/v1/hubs/by-school/{school_id}/
Response (200 OK): Same as GET /api/v1/hubs/{hub_id}/
Response (404 Not Found):
{
"error": "Hub not found for this school"
}
Caching & Visibility Behavior:
- Cache: Cached as hub-by-school:{school_id} using CACHE_TTL_SHORT (60 seconds).
- Invalidation: Same invalidation triggers as Get Hub.
Get Hub by Slug
Endpoint: GET /api/v1/hubs/by-slug/{slug}/
Response (200 OK): Same as GET /api/v1/hubs/{hub_id}/
Response (404 Not Found):
{
"error": "Hub not found for this school"
}
Caching & Visibility Behavior:
- Cache: Cached as hub-by-slug:{slug} using CACHE_TTL_SHORT (60 seconds).
- Invalidation: Same invalidation triggers as Get Hub.
Request Hub Activation
Endpoint: POST /api/v1/hubs/activation-requests/
Request:
{
"school_id": "uuid",
"notes": "Our university needs a hub for students"
}
Response (201 Created):
{
"id": "uuid",
"school": {
"id": "uuid",
"name": "University of Lagos",
"short_name": "UNILAG",
"slug": "unilag",
"location": "Lagos, Nigeria"
},
"user": {
"id": "uuid",
"full_name": "John Doe"
},
"status": "PENDING",
"notes": "Our university needs a hub for students",
"created_at": "2026-01-01T00:00:00Z"
}
Error Responses:
// 400 Bad Request - School already has an active hub
{
"school_id": ["This school already has an active hub."]
}
// 400 Bad Request - A pending request already exists for this school
{
"school_id": ["There is already a pending activation request for this school."]
}
// 400 Bad Request - School doesn't exist
{
"school_id": ["School not found."]
}
A new request is blocked if the target school already has an active hub, or if a
PENDING request for that school already exists, this prevents duplicate/spam
requests from piling up before an admin reviews the first one.
Submitting a request sends every active admin a NEW_ACTIVATION_REQUEST
notification, in-app only, excluding the requester if they happen to be an
admin.
List Activation Requests (Admin Only)
Endpoint: GET /api/v1/hubs/activation-requests/
Query Parameters:
- status - PENDING/APPROVED/REJECTED
- page - Page number
Response (200 OK):
{
"count": 5,
"results": [
{
"id": "uuid",
"school": {
"id": "uuid",
"name": "University of Lagos",
"short_name": "UNILAG"
},
"user": {
"id": "uuid",
"full_name": "John Doe"
},
"status": "PENDING",
"notes": "Our university needs a hub for students",
"created_at": "2026-01-01T00:00:00Z"
}
]
}
Approve Activation Request (Admin Only)
Endpoint: POST /api/v1/hubs/activation-requests/{request_id}/approve/
Response (200 OK):
{
"message": "Hub activated successfully",
"hub": {
"id": "uuid",
"school": {
"id": "uuid",
"name": "University of Lagos",
"short_name": "UNILAG"
},
"activated_at": "2026-01-01T00:00:00Z"
}
}
Error Response (400 Bad Request):
{
"error": "This activation request has already been reviewed"
}
Approving sends the requesting user a HUB_ACTIVATED notification (in-app and
email).
Invalidation Note: Automatically invalidates hub-detail:{hub_id}, hub-by-school:{school_id}, hub-by-slug:{slug}, school-detail:{school_id}, and the school-list prefix.
Reject Activation Request (Admin Only)
Endpoint: POST /api/v1/hubs/activation-requests/{request_id}/reject/
Request:
{
"reason": "School not verified"
}
Response (200 OK):
{
"message": "Activation request rejected",
"status": "REJECTED"
}
Error Response (400 Bad Request):
{
"error": "This activation request has already been reviewed"
}
Rejecting sends the requesting user a HUB_ACTIVATION_REJECTED notification
(email and in-app). The reason field is used in the email body if provided,
but is not currently persisted anywhere, it does not appear in
GET /hubs/activation-requests/ or anywhere else after the email sends.
Departments
List Departments
Endpoint: GET /api/v1/schools/{school_id}/departments/
Response (200 OK):
{
"results": [
{
"id": "uuid",
"name": "Computer Science",
"code": "CSC",
"question_count": 42,
"is_active": true
}
]
}
This endpoint is not paginated. For regular users and guests, only active
departments (is_active: true) are returned. Authenticated Platform Admins and
School Representatives for this school's hub see all departments, including
inactive ones (so they can reactivate them via PATCH).
Create Department (School Representative or Admin)
Endpoint: POST /api/v1/schools/{school_id}/departments/
Request:
{
"name": "Data Science",
"code": "DSC"
}
code is optional (can be omitted or sent as null). name is required and is
case-sensitively unique per school (enforced at the database level).
Response (201 Created): Same as department object in List Departments above
Error Response (400 Bad Request):
{
"name": ["A department with this name already exists for this school."]
}
Error Response (403 Forbidden):
{
"error": "You do not have permission to perform this action"
}
Requires an active SchoolRepresentativeAssignment for this school's hub, or
platform admin status.
Invalidation Note: Creating a department invalidates the school-detail:{school_id} cache key of its parent school.
Update Department (School Representative or Admin)
Endpoint: PATCH /api/v1/departments/{department_id}/
Request:
{
"name": "Computer Science and Engineering",
"code": "CSE",
"is_active": false
}
All fields are optional (partial PATCH). Sending "is_active": false is the
soft-delete path; setting it back to true reactivates the department.
Response (200 OK): Same as department object
Error Response (403 Forbidden):
{
"error": "You do not have permission to perform this action"
}
Same permission requirement as Create Department above.
Invalidation Note: Updating a department invalidates the school-detail:{school_id} cache key for its corresponding parent school.
Questions
List Questions
Endpoint: GET /api/v1/questions/
Query Parameters:
- hub - Filter by hub ID
- department - Filter by department ID
- status - OPEN/ANSWERED/SOLVED
- tag - Filter by tag name
- author - Filter by author user ID
- search - Search in title and body
- ordering - created_at/-created_at/views/-views
- page - Page number
- page_size - Items per page (default: 20, max: 50)
Response (200 OK):
{
"count": 150,
"next": "https://api.academia.com/api/v1/questions/?page=2",
"previous": null,
"results": [
{
"id": "uuid",
"title": "How do I calculate my CGPA?",
"slug": "how-do-i-calculate-my-cgpa",
"body": "I'm confused about the grading system...",
"status": "SOLVED",
"view_count": 120,
"is_locked": false,
"author": {
"id": "uuid",
"full_name": "John Doe",
"avatar": "https://..."
},
"hub": {
"id": "uuid",
"school": {
"name": "University of Lagos",
"short_name": "UNILAG",
"slug": "unilag"
}
},
"department": {
"id": "uuid",
"name": "Computer Science"
},
"tags": ["gpa", "grading", "calculation"],
"answer_count": 3,
"best_answer_id": "uuid",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
]
}
Create Question
Endpoint: POST /api/v1/questions/
Rate limited to 30 requests per hour per user (see Rate Limits below).
Request:
{
"title": "How do I calculate my CGPA?",
"body": "I'm confused about the grading system at UNILAG...",
"hub_id": "uuid",
"department_id": "uuid",
"tags": ["gpa", "grading", "calculation"]
}
Response (201 Created): Same as GET /api/v1/questions/{question_id}/
Error Responses:
// 400 Bad Request - Missing required fields
{
"title": ["This field is required."],
"body": ["This field is required."],
"hub_id": ["This field is required."]
}
// 400 Bad Request - Hub doesn't have this department
{
"department_id": ["Department does not belong to this hub's school."]
}
// 400 Bad Request - Hub doesn't exist
{
"hub_id": ["Hub with ID 'uuid' does not exist."]
}
Creating a question sends a NEW_QUESTION notification, in-app only, to
every user with an active ModeratorAssignment for that hub, excluding the
question's own author.
Get Question
Endpoint: GET /api/v1/questions/{question_id}/
Response (200 OK):
{
"id": "uuid",
"title": "How do I calculate my CGPA?",
"slug": "how-do-i-calculate-my-cgpa",
"body": "I'm confused about the grading system...",
"status": "SOLVED",
"view_count": 120,
"is_locked": false,
"is_following": false,
"author": {
"id": "uuid",
"full_name": "John Doe",
"avatar": "https://..."
},
"hub": {
"id": "uuid",
"school": {
"name": "University of Lagos",
"short_name": "UNILAG",
"slug": "unilag"
}
},
"department": {
"id": "uuid",
"name": "Computer Science"
},
"tags": ["gpa", "grading", "calculation"],
"answers": [
{
"id": "uuid",
"body": "To calculate your CGPA...",
"is_best": true,
"vote_score": 15,
"user_vote": "UP",
"author": {
"id": "uuid",
"full_name": "Jane Smith",
"avatar": "https://..."
},
"comment_count": 2,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
],
"answer_count": 3,
"best_answer_id": "uuid",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
Response (404 Not Found):
{
"error": "Question not found"
}
view_count only increments for authenticated requests, anonymous views are
never counted. Increments are deduplicated per user for 24 hours (a rolling
window, not a calendar-day reset), a signed-in user repeatedly refreshing the
same question within that window will not keep inflating the count. This is
cache-backed, not a permanent per-user view record, no history of who viewed
what is stored anywhere.
is_following reflects only the requesting user's own follow state, always
false for anonymous requests, see Follow Question below.
Update Question
Endpoint: PATCH /api/v1/questions/{question_id}/
Request:
{
"title": "Updated title",
"body": "Updated body",
"department_id": "uuid",
"tags": ["gpa", "updated"]
}
Response (200 OK): Same as GET /api/v1/questions/{question_id}/
Error Response (403 Forbidden):
{
"error": "You do not have permission to edit this question"
}
Note:
- status is not an editable field on this endpoint. It changes only as a side
effect: creating the first answer moves a question from OPEN to ANSWERED,
marking a best answer moves it to SOLVED, deleting an answer can move it back
to ANSWERED or OPEN depending on what remains (see Delete Answer below).
- SOLVED means "this question has a designated best answer," not "this
question is closed." New answers can be submitted to a question at any status,
including SOLVED, a question is never locked to further input purely because
an owner picked a favorite. Adding a plain answer to a SOLVED question does
not change its status, only removing the current best answer (or the owner
marking a different answer best) affects SOLVED status.
- slug on Question is purely cosmetic, meant for building readable URLs like
/questions/{id}/{slug}, it is not unique and is not used for lookups. Editing a
question's title regenerates its slug.
Delete Question
Endpoint: DELETE /api/v1/questions/{question_id}/
Response (204 No Content): Empty
Error Response (403 Forbidden):
{
"error": "You do not have permission to delete this question"
}
Get Unanswered Questions (Moderator or Admin)
Endpoint: GET /api/v1/questions/unanswered/
Query Parameters:
- hub - Filter by hub ID
- search - Search in title and body
- page - Page number
Response (200 OK): Same as list questions, but only OPEN status
Error Response (403 Forbidden):
{
"error": "You do not have permission to view unanswered questions"
}
A platform admin sees unanswered questions across every hub. A user with an active
ModeratorAssignment sees only questions belonging to hub(s) they are assigned to
moderate. A user with neither is blocked with a 403.
Follow Question
Endpoint: POST /api/v1/questions/{question_id}/follow/
Response (201 Created):
{
"message": "Now following question",
"is_following": true
}
Error Response (400 Bad Request):
{
"error": "You are already following this question"
}
Unfollow Question
Endpoint: DELETE /api/v1/questions/{question_id}/follow/
Response (204 No Content): Empty
Error Response (404 Not Found):
{
"error": "You have not followed this question"
}
Following is independent of authorship, a question's own author can follow their own question but doesn't need to, they already receive notifications for it by default. Followers get an in-app-only NEW_ANSWER notification (varied message text, no new notification type) when the question receives a new answer or when a best answer is marked, excluding whichever user is already notified through the existing author-facing paths. No follower count is exposed anywhere in the API, is_following reflects only the requesting user's own follow state.
Lock Question (Admin, Representative, or Moderator Only)
Endpoint: POST /api/v1/questions/{question_id}/lock/
Locks the question so that new answers can no longer be added.
Response (200 OK):
{
"message": "Question locked successfully",
"is_locked": true
}
Error Response (400 Bad Request):
{
"error": "Question is already locked"
}
Error Response (403 Forbidden):
{
"error": "You do not have permission to lock this question"
}
Unlock Question (Admin, Representative, or Moderator Only)
Endpoint: DELETE /api/v1/questions/{question_id}/lock/
Unlocks the question to allow new answers to be submitted again.
Response (200 OK):
{
"message": "Question unlocked successfully",
"is_locked": false
}
Error Response (400 Bad Request):
{
"error": "Question is not locked"
}
Error Response (403 Forbidden):
{
"error": "You do not have permission to unlock this question"
}
Answers
Create Answer
Endpoint: POST /api/v1/answers/
Rate limited to 50 requests per hour per user (see Rate Limits below).
Request:
{
"question_id": "uuid",
"body": "To calculate your CGPA, you need to..."
}
Response (201 Created):
{
"id": "uuid",
"body": "To calculate your CGPA, you need to...",
"question": {
"id": "uuid",
"title": "How do I calculate my CGPA?"
},
"author": {
"id": "uuid",
"full_name": "Jane Smith",
"avatar": "https://..."
},
"is_best": false,
"vote_score": 0,
"user_vote": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
Error Responses:
// 400 Bad Request
{
"question_id": ["This field is required."],
"body": ["This field is required."]
}
// 400 Bad Request - Question Locked
{
"question_id": ["This question is locked and does not accept new answers."]
}
Answering someone else's question sends them a NEW_ANSWER notification (in-app
and email), regardless of the question's current status. Answers can be added
to a question at any status, including SOLVED, see the Update Question note
below on what SOLVED actually means. Followers of the question (see Follow
Question above) also receive an in-app-only NEW_ANSWER notification, excluding
the answering user and the question author.
Update Answer
Endpoint: PATCH /api/v1/answers/{answer_id}/
Request:
{
"body": "Updated answer content"
}
Response (200 OK): Same as GET answer object
Error Response (403 Forbidden):
{
"error": "You do not have permission to edit this answer"
}
Delete Answer
Endpoint: DELETE /api/v1/answers/{answer_id}/
Response (204 No Content): Empty
Error Response (403 Forbidden):
{
"error": "You do not have permission to delete this answer"
}
If the deleted answer was the question's best answer and other answers remain, the question's status reverts from SOLVED to ANSWERED. If it was the last remaining answer on the question, status reverts to OPEN.
Mark Best Answer
Endpoint: POST /api/v1/answers/{answer_id}/mark-best/
Response (200 OK):
{
"message": "Answer marked as best",
"answer": {
"id": "uuid",
"is_best": true
},
"question": {
"id": "uuid",
"status": "SOLVED"
}
}
Error Responses:
// 403 Forbidden - Not the question owner
{
"error": "Only the question owner can mark a best answer"
}
// 400 Bad Request - Answer already marked as best
{
"error": "This answer is already marked as best"
}
Marking a different answer as best on an already-solved question transfers the
best-answer flag to the new answer and leaves the question SOLVED. Only marking the
same answer that is already best is blocked. If the newly marked answer belongs to
someone other than the question owner, they receive a BEST_ANSWER notification
(in-app and email). Followers of the question also receive an in-app-only
NEW_ANSWER notification, excluding the question owner and the marked answer's
author.
Answer Votes
Vote on Answer
Endpoint: POST /api/v1/answers/{answer_id}/vote/
Rate limited to 100 requests per hour per user, shared with Remove Vote below (see Rate Limits below).
Request:
{
"vote_type": "UP"
}
Response (200 OK):
{
"message": "Vote recorded",
"vote_type": "UP",
"vote_score": 15
}
Error Responses:
// 400 Bad Request - Already voted
{
"error": "You have already voted on this answer"
}
// 400 Bad Request - Can't vote on own answer
{
"error": "You cannot vote on your own answer"
}
// 400 Bad Request - Invalid vote type
{
"vote_type": ["This field must be one of: UP, DOWN."]
}
Voting sends the answer's author a VOTE notification, in-app only, no email.
There is no dedicated "change my vote" endpoint, remove your existing vote first,
then vote again with the new type.
user_vote ("UP", "DOWN", or null) appears on every answer object returned
from Get Question, Create Answer, and Update Answer, reflecting the requesting
user's own vote on that specific answer. It is always null for anonymous
requests, and always null on an answer's own author (self-voting is blocked).
Use this to restore vote-button state correctly on page load, without it the
frontend would only know about votes cast during the current session.
Remove Vote
Endpoint: DELETE /api/v1/answers/{answer_id}/vote/
Response (204 No Content): Empty
Error Response (404 Not Found):
{
"error": "You have not voted on this answer"
}
List My Answers
Endpoint: GET /api/v1/users/me/answers/
Response (200 OK): Same shape as list questions' pagination envelope,
results are answer objects in the same shape as Create Answer's response
(includes question, is_best, vote_score, user_vote). Requires authentication,
always scoped to the requesting user, there is no way to fetch another user's
answers through this or any other endpoint. Ordered by most recent first.
Comments
List Comments
Endpoint: GET /api/v1/answers/{answer_id}/comments/
Response (200 OK):
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": "uuid",
"body": "Does this vary by school?",
"author": {
"id": "uuid",
"full_name": "John Doe",
"avatar": "https://..."
},
"answer": {
"id": "uuid"
},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
]
}
Response (404 Not Found):
{
"error": "Answer not found"
}
This endpoint is public — no authentication required. Pagination uses the same
envelope as all list endpoints (count, next, previous, results).
Create Comment
Endpoint: POST /api/v1/comments/
Rate limited to 50 requests per hour per user (see Rate Limits below).
Request:
{
"answer_id": "uuid",
"body": "Could you elaborate on the grading scale?"
}
Response (201 Created):
{
"id": "uuid",
"body": "Could you elaborate on the grading scale?",
"author": {
"id": "uuid",
"full_name": "John Doe",
"avatar": "https://..."
},
"answer": {
"id": "uuid"
},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
Error Responses:
// 400 Bad Request
{
"answer_id": ["This field is required."],
"body": ["This field is required."]
}
// 400 Bad Request - Answer doesn't exist
{
"answer_id": ["Answer with this ID does not exist."]
}
Commenting on someone else's answer sends them a NEW_COMMENT notification,
in-app only, no email.
Update Comment
Endpoint: PATCH /api/v1/comments/{comment_id}/
Request:
{
"body": "Updated comment"
}
Response (200 OK): Same as comment object
Error Response (403 Forbidden):
{
"error": "You do not have permission to edit this comment"
}
Delete Comment
Endpoint: DELETE /api/v1/comments/{comment_id}/
Response (204 No Content): Empty
Error Response (403 Forbidden):
{
"error": "You do not have permission to delete this comment"
}
List My Comments
Endpoint: GET /api/v1/users/me/comments/
Response (200 OK):
{
"count": 21,
"results": [
{
"id": "uuid",
"body": "Does this vary by school?",
"answer": {
"id": "uuid",
"question": { "id": "uuid", "title": "How do I calculate my CGPA?" }
},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
]
}
Requires authentication, always scoped to the requesting user. Ordered by most recent first. There is no equivalent "my questions" endpoint, use GET /questions/?author={my_user_id} with the id from GET /users/me/, since questions are already public content and the existing author filter covers this without duplicating an endpoint.
Tags
List Tags
Endpoint: GET /api/v1/tags/
Query Parameters:
- search - Search by tag name
- popular - Sort by question count (true/false)
Response (200 OK):
{
"results": [
{
"id": "uuid",
"name": "gpa",
"question_count": 25
},
{
"id": "uuid",
"name": "admission",
"question_count": 18
}
]
}
This endpoint is not paginated, it returns every matching tag in one response. Tag names are normalized to lowercase everywhere, on creation, search, and every filter, so "GPA" and "gpa" are always treated as the same tag.
Caching & Visibility Behavior:
- Cache: Public requests are cached using tag-list:<query_params> key for CACHE_TTL_SHORT (60 seconds).
- Invalidation: Clears only when the cache TTL expires (since tags are created implicitly during question creation/update).
Get Questions by Tag
Endpoint: GET /api/v1/tags/{tag_name}/questions/
Query Parameters: - Same as list questions
Response (200 OK): Same as list questions. A tag name with no matching
questions returns 200 OK with an empty result set, not a 404, consistent with
how every other query-param filter on this API behaves.
Delete Tag (Admin Only)
Endpoint: DELETE /api/v1/tags/{tag_id}/
Query Parameters:
- force - true to delete a tag that still has questions attached (default: not forced)
Response (204 No Content): Empty
Error Response (400 Bad Request):
{
"error": "This tag is attached to 12 questions. Pass ?force=true to delete it anyway."
}
Response (404 Not Found):
{
"error": "Tag not found"
}
By default, deleting a tag that's still attached to any question is blocked, to
avoid silently orphaning content. Pass ?force=true to delete it anyway, this
removes the tag from every question it's on (via cascade on QuestionTag), it
does not delete the questions themselves.
Invalidation Note: Invalidates the tag-list cache prefix.
Merge Tag (Admin Only)
Endpoint: POST /api/v1/tags/{tag_id}/merge/
Merges the tag in the URL (the source) into a target tag, reassigning every question tagged with the source over to the target, then removes the source tag. The target can be specified two ways:
Request, merge into an existing tag:
{
"target_tag_id": "uuid"
}
Request, rename (or merge-by-name):
{
"target_name": "siwes"
}
Exactly one of target_tag_id / target_name must be provided. When
target_name is given: if a tag with that name (after lowercase
normalization) already exists, this behaves identically to target_tag_id, a
real merge. If no tag with that name exists yet, the source tag is simply
renamed in place, a rename is just a merge into a name nobody holds yet, so
there is no separate rename endpoint.
Response (200 OK), merge:
{
"message": "Tags merged successfully",
"tag": {
"id": "uuid",
"name": "siwes",
"question_count": 40
},
"questions_reassigned": 15
}
Response (200 OK), rename:
{
"message": "Tag renamed successfully",
"tag": {
"id": "uuid",
"name": "siwes",
"question_count": 12
}
}
questions_reassigned only appears on the merge response, and counts
questions actually moved over, if a question already carried both the source
and target tag, its duplicate source association is simply dropped rather
than counted as a reassignment.
Error Responses:
// 400 Bad Request - Neither field provided
{
"error": "Provide either target_tag_id or target_name."
}
// 400 Bad Request - Both fields provided
{
"error": "Provide only one of target_tag_id or target_name, not both."
}
// 400 Bad Request - Merging a tag into itself
{
"error": "Cannot merge a tag into itself."
}
// 400 Bad Request - target_tag_id doesn't exist
{
"target_tag_id": ["Tag with this ID does not exist."]
}
// 400 Bad Request - Malformed target_tag_id
{
"error": "Invalid ID format"
}
Response (404 Not Found):
{
"error": "Tag not found"
}
Invalidation Note: Invalidates the tag-list cache prefix.
Search
Search Questions
Endpoint: GET /api/v1/search/questions/
Rate limited to 60 requests per minute (see Rate Limits below).
Query Parameters:
- q - Search query
- hub - Filter by hub ID
- school - Filter by school ID
- department - Filter by department ID
- tag - Filter by tag name
- page - Page number
Response (200 OK):
{
"count": 45,
"next": "https://api.academia.com/api/v1/search/questions/?page=2",
"previous": null,
"results": [
{
"id": "uuid",
"title": "How do I calculate my CGPA?",
"slug": "how-do-i-calculate-my-cgpa",
"body": "I'm confused about the grading system...",
"status": "SOLVED",
"score": 0.4213,
"author": {
"id": "uuid",
"full_name": "John Doe",
"avatar": "https://..."
},
"hub": {
"id": "uuid",
"school": {
"name": "University of Lagos",
"short_name": "UNILAG",
"slug": "unilag"
}
},
"tags": ["gpa", "grading"],
"created_at": "2026-01-01T00:00:00Z"
}
]
}
Note: Results are ranked by: 1. Solved questions (status=SOLVED) with best answers 2. Highly voted answers 3. Recent content 4. Relevance to search terms
score is a raw PostgreSQL ts_rank value, higher means more textually relevant,
but it has no fixed upper bound (it is not a normalized 0 to 1 percentage). It is
null whenever q is omitted, since relevance has no meaning without a query to
rank against, in that case results still return, ordered by the first three
ranking priorities above.
Caching & Visibility Behavior:
- Cache: Public requests are cached using search-questions:<query_params> key for CACHE_TTL_SEARCH (30 seconds).
- Invalidation: TTL expiry only, no active invalidation trigger exists for search results, given the short TTL and the volume of writes (new answers, votes, status changes) that could affect ranking, active invalidation wasn't worth the complexity.
Notifications
List Notifications
Endpoint: GET /api/v1/notifications/
Query Parameters:
- is_read - true/false
- page - Page number
Response (200 OK):
{
"count": 25,
"unread_count": 5,
"results": [
{
"id": "uuid",
"type": "NEW_ANSWER",
"message": "Jane Smith answered your question: 'How do I calculate my CGPA?'",
"is_read": false,
"related_object_id": "uuid",
"related_object_type": "question",
"created_at": "2026-01-01T00:00:00Z"
}
]
}
related_object_type/related_object_id are serialized from the underlying
ContentType-based generic relation (see database-schema.md), the JSON shape
doesn't change even as new notifiable models are added.
Notification types currently triggered: NEW_ANSWER (email and in-app),
BEST_ANSWER (email and in-app), HUB_ACTIVATED (email and in-app),
NEW_COMMENT (in-app only), VOTE (in-app only), MODERATOR_ASSIGNED
(in-app only, fires for both moderator and representative assignment),
NEW_QUESTION (in-app only, sent to every active moderator AND
representative of the hub a new question is posted in, excluding the
question's own author, deduplicated if a user holds both roles),
NEW_ACTIVATION_REQUEST (in-app only, sent to every active admin when a
new hub activation request is submitted, excluding the requester if they
happen to be an admin), NEW_REPORT (in-app only, sent to every active
admin when a new report is submitted, excluding the reporter if they
happen to be an admin), USER_SUSPENDED (email and in-app, fires only on
the transition from active to suspended, not on a repeated suspend call
or on reactivation), HUB_ACTIVATION_REJECTED (email and in-app, sent to
the original requester when their activation request is rejected),
REPORT_REVIEWED (in-app only, sent to the original reporter when their
report is resolved or rejected, excluding the reporter if they happen to
be the reviewing admin).
Mark Notification as Read
Endpoint: POST /api/v1/notifications/{notification_id}/mark-read/
Response (200 OK):
{
"message": "Notification marked as read",
"is_read": true
}
Error Responses:
// 403 Forbidden - Not your notification
{
"error": "You do not have permission to modify this notification"
}
// 404 Not Found
{
"error": "Notification not found"
}
Mark All Notifications as Read
Endpoint: POST /api/v1/notifications/mark-all-read/
Response (200 OK):
{
"message": "All notifications marked as read",
"count": 5
}
Reports
Create Report
Endpoint: POST /api/v1/reports/
Rate limited to 10 requests per hour per user (see Rate Limits below).
Request:
{
"content_type": "question",
"content_id": "uuid",
"type": "SPAM",
"description": "This appears to be promotional content"
}
Response (201 Created):
{
"id": "uuid",
"content_type": "question",
"content_id": "uuid",
"type": "SPAM",
"description": "This appears to be promotional content",
"status": "PENDING",
"created_at": "2026-01-01T00:00:00Z"
}
content_type/content_id are serialized from the underlying ContentType-based
generic relation (see database-schema.md), the JSON shape stays stable even as
new reportable models (e.g. SchoolReview) are added later. Supported values for
content_type are question, answer, and comment.
Error Responses:
// 400 Bad Request - Already reported by this user
{
"error": "You have already reported this content"
}
// 400 Bad Request - Invalid content_type
{
"content_type": ["content_type must be one of: question, answer, comment."]
}
// 400 Bad Request - No matching content
{
"content_id": ["No matching content found for this content_type and content_id."]
}
Submitting a report sends every active admin a NEW_REPORT notification,
in-app only, excluding the reporter if they happen to be an admin. The
notification message includes the report's type for quick triage from
the notification list itself, without needing to open the report first.
A user cannot report the same piece of content more than once, this restriction applies regardless of whether an earlier report on that content was resolved or rejected.
List Reports (Admin Only)
Endpoint: GET /api/v1/reports/
Query Parameters:
- status - PENDING/RESOLVED/REJECTED
- is_escalated - true/false
- page - Page number
Response (200 OK):
{
"count": 10,
"results": [
{
"id": "uuid",
"content_type": "question",
"content_id": "uuid",
"type": "SPAM",
"description": "This appears to be promotional content",
"status": "PENDING",
"reporter": {
"id": "uuid",
"full_name": "John Doe"
},
"is_escalated": false,
"escalated_by": null,
"created_at": "2026-01-01T00:00:00Z"
}
]
}
Resolve Report (Admin Only)
Endpoint: POST /api/v1/reports/{report_id}/resolve/
Request:
{
"action": "DELETE_CONTENT"
}
Response (200 OK):
{
"message": "Report resolved",
"status": "RESOLVED",
"action_taken": "DELETE_CONTENT",
"resolved_at": "2026-01-01T00:00:00Z"
}
Error Response (400 Bad Request):
{
"error": "This report has already been reviewed"
}
Resolving sends the original reporter a REPORT_REVIEWED notification,
in-app only, excluding the reporter if they are also the resolving admin.
action: "DELETE_CONTENT" is the only value with special behavior, it deletes the
underlying reported object. Any other value, or omitting action entirely, resolves
the report without taking any action on the content itself, useful when a report is
valid to acknowledge but doesn't warrant removal.
Reject Report (Admin Only)
Endpoint: POST /api/v1/reports/{report_id}/reject/
Response (200 OK):
{
"message": "Report rejected",
"status": "REJECTED",
"resolved_at": "2026-01-01T00:00:00Z"
}
Error Response (400 Bad Request):
{
"error": "This report has already been reviewed"
}
Rejecting sends the original reporter a REPORT_REVIEWED notification,
in-app only, excluding the reporter if they are also the rejecting admin.
Escalate Report
Endpoint: POST /api/v1/reports/{report_id}/escalate/
Restricted to an active Moderator or Representative for the reported content's
hub, or an admin. Marks an existing PENDING report as escalated, signaling
that a staff member's judgment (not just a student complaint) flagged it for
priority review. Requires an existing report, there is no way to escalate
unreported content directly, file a report first via POST /reports/ then
escalate it.
Response (200 OK):
{
"id": "uuid",
"content_type": "question",
"content_id": "uuid",
"type": "SPAM",
"description": "This appears to be promotional content",
"status": "PENDING",
"reporter": {
"id": "uuid",
"full_name": "John Doe"
},
"is_escalated": true,
"escalated_by": {
"id": "uuid",
"full_name": "Jane Moderator"
},
"created_at": "2026-01-01T00:00:00Z"
}
Error Responses:
// 400 Bad Request - Already reviewed
{
"error": "This report has already been reviewed"
}
// 400 Bad Request - Already escalated
{
"error": "This report has already been escalated"
}
// 400 Bad Request - Reported content no longer exists
{
"error": "The reported content no longer exists"
}
// 403 Forbidden - No moderator/representative role for this hub, and not admin
{
"error": "You do not have permission to perform this action"
}
Escalating sends every active admin a NEW_REPORT notification, in-app only,
excluding the escalator if they happen to be an admin. This reuses the same
notification type as report creation, with message text that specifically
frames it as an escalation, matching the project's precedent of reusing
notification types over adding new ones for closely related events.
Escalate Report
Endpoint: POST /api/v1/reports/{report_id}/escalate/
Restricted to an active Moderator or Representative for the reported content's
hub, or an admin. Marks an existing PENDING report as escalated, signaling
that a staff member's judgment (not just a student complaint) flagged it for
priority review. Requires an existing report, there is no way to escalate
unreported content directly, file a report first via POST /reports/ then
escalate it.
Response (200 OK):
{
"id": "uuid",
"content_type": "question",
"content_id": "uuid",
"type": "SPAM",
"description": "This appears to be promotional content",
"status": "PENDING",
"reporter": {
"id": "uuid",
"full_name": "John Doe"
},
"is_escalated": true,
"escalated_by": {
"id": "uuid",
"full_name": "Jane Moderator"
},
"created_at": "2026-01-01T00:00:00Z"
}
Error Responses:
// 400 Bad Request - Already reviewed
{
"error": "This report has already been reviewed"
}
// 400 Bad Request - Already escalated
{
"error": "This report has already been escalated"
}
// 400 Bad Request - Reported content no longer exists
{
"error": "The reported content no longer exists"
}
// 403 Forbidden - No moderator/representative role for this hub, and not admin
{
"error": "You do not have permission to perform this action"
}
Escalating sends every active admin a NEW_REPORT notification, in-app only,
excluding the escalator if they happen to be an admin. This reuses the same
notification type as report creation, with message text that specifically
frames it as an escalation, matching the project's precedent of reusing
notification types over adding new ones for closely related events.
Moderation
Assign Moderator (School Representative or Admin)
Endpoint: POST /api/v1/hubs/{hub_id}/moderators/
Request:
{
"user_id": "uuid"
}
Response (201 Created):
{
"id": "uuid",
"user": {
"id": "uuid",
"full_name": "Jane Smith",
"avatar": "https://..."
},
"hub": {
"id": "uuid"
},
"is_active": true,
"created_at": "2026-01-01T00:00:00Z"
}
Error Responses:
// 400 Bad Request - Already a moderator
{
"error": "User is already a moderator for this hub"
}
// 403 Forbidden - Not a representative for this hub, and not an admin
{
"error": "You do not have permission to perform this action"
}
// 404 Not Found - User doesn't exist
{
"user_id": ["User with ID 'uuid' does not exist."]
}
Assigning a moderator sends the assigned user a MODERATOR_ASSIGNED
notification, in-app only, unless they assigned themselves.
Requires an active SchoolRepresentativeAssignment for this hub, or platform
admin status.
Invalidation Note: Assigning a moderator invalidates hub-detail:{hub_id}, hub-by-school:{school_id}, hub-by-slug:{slug}, and school-detail:{school_id} cache keys.
Remove Moderator (School Representative or Admin)
Endpoint: DELETE /api/v1/hubs/{hub_id}/moderators/{user_id}/
Response (204 No Content): Empty
Error Response (404 Not Found):
{
"error": "Moderator assignment not found"
}
Removal is a soft-delete (is_active: false), not a hard row delete.
Invalidation Note: Removing a moderator invalidates the same cache keys as Assign Moderator.
List Moderators
Endpoint: GET /api/v1/hubs/{hub_id}/moderators/
Response (200 OK):
{
"results": [
{
"id": "uuid",
"user": {
"id": "uuid",
"full_name": "Jane Smith",
"avatar": "https://..."
},
"hub": {
"id": "uuid"
},
"is_active": true,
"created_at": "2026-01-01T00:00:00Z"
}
]
}
This endpoint is public, no authentication required. It is not paginated, it returns every active moderator for the hub in one response.
School Representatives
Assign School Representative (Admin Only)
Endpoint: POST /api/v1/hubs/{hub_id}/representatives/
Request:
{
"user_id": "uuid"
}
Response (201 Created): Same shape as moderator assignment above
Error Responses:
// 400 Bad Request - Already a representative
{
"error": "User is already a representative for this hub"
}
// 404 Not Found - User doesn't exist
{
"user_id": ["User with ID 'uuid' does not exist."]
}
List Representatives
Endpoint: GET /api/v1/hubs/{hub_id}/representatives/
Response (200 OK):
{
"results": [
{
"id": "uuid",
"user": {
"id": "uuid",
"full_name": "Jane Smith",
"avatar": "https://..."
},
"hub": {
"id": "uuid"
},
"is_active": true,
"created_at": "2026-01-01T00:00:00Z"
}
]
}
Assigning a representative sends the assigned user a MODERATOR_ASSIGNED
notification (message text distinguishes the representative role from
moderator), in-app only, unless they assigned themselves.
This endpoint is public, no authentication required, matching List Moderators. It is not paginated, it returns every active representative for the hub in one response.
Remove School Representative (Admin Only)
Endpoint: DELETE /api/v1/hubs/{hub_id}/representatives/{user_id}/
Response (204 No Content): Empty
Error Response (404 Not Found):
{
"error": "Representative assignment not found"
}
Removal is a soft-delete (is_active: false), not a hard row delete.
User Administration
List Users (Admin Only)
Endpoint: GET /api/v1/admin/users/
Query Parameters:
- search - Search by email or full_name
- is_active - Filter by account active status (true/false)
- page - Page number
Response (200 OK):
{
"count": 500,
"next": "https://api.academia.com/api/v1/admin/users/?page=2",
"previous": null,
"results": [
{
"id": "uuid",
"email": "user@example.com",
"full_name": "John Doe",
"avatar": "https://...",
"is_admin": false,
"is_active": true,
"created_at": "2026-01-01T00:00:00Z"
}
]
}
Get User (Admin Only)
Endpoint: GET /api/v1/admin/users/{user_id}/
Response (200 OK): Same shape as a single result above
Response (404 Not Found):
{
"error": "User not found"
}
Suspend or Reactivate User (Admin Only)
Endpoint: PATCH /api/v1/admin/users/{user_id}/
Request:
{
"is_active": false
}
Response (200 OK): Same shape as Get User above
Error Response (400 Bad Request):
{
"error": "You cannot suspend your own account"
}
This endpoint only accepts is_active. Promoting or demoting is_admin status is
not available through this endpoint in the MVP. An admin cannot suspend their own
account, to prevent accidental lockout.
Static Pages
List Pages
Endpoint: GET /api/v1/pages/
Response (200 OK):
{
"results": [
{
"id": "uuid",
"title": "Privacy Policy",
"slug": "privacy-policy",
"visibility": "PUBLIC",
"is_published": true,
"created_by": { "id": "uuid", "full_name": "Jane Admin" },
"updated_at": "2026-01-01T00:00:00Z"
}
]
}
Not paginated, low volume. Visibility is computed per requester:
- Anonymous or plain authenticated users see published PUBLIC pages only.
- Staff (is_admin, or an active moderator/representative assignment for
any hub) additionally see published STAFF pages.
- Admins see everything, including unpublished drafts of either visibility.
created_by is null if the creating user's account has since been deleted.
Get Page
Endpoint: GET /api/v1/pages/{slug}/
Response (200 OK):
{
"id": "uuid",
"title": "Privacy Policy",
"slug": "privacy-policy",
"body": "# Privacy Policy\n\nWe respect your data...",
"visibility": "PUBLIC",
"is_published": true,
"created_by": { "id": "uuid", "full_name": "Jane Admin" },
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
Response (404 Not Found):
{
"error": "Page not found"
}
Same visibility rules as List Pages, applied per-page. A page that exists but
isn't visible to the requester returns the identical 404 as a page that
genuinely doesn't exist, a STAFF-only or draft page's existence is never
confirmable to a request that can't see it.
Create Page (Admin Only)
Endpoint: POST /api/v1/pages/
Request:
{
"title": "Privacy Policy",
"body": "# Privacy Policy\n\nWe respect your data...",
"visibility": "PUBLIC",
"is_published": true
}
Response (201 Created): Same shape as Get Page
slug is auto-generated from title and is not accepted in the request
body, it cannot be set directly on creation.
Update Page (Admin Only)
Endpoint: PATCH /api/v1/pages/{page_id}/
Request: Same fields as Create, all optional (partial update)
Response (200 OK): Same shape as Get Page
Error Response (400 Bad Request):
{
"error": "Invalid ID format"
}
Note: {page_id} here is the page's UUID, not its slug, distinct from Get
Page above which looks up by slug. Editing title does not regenerate
slug, it's generated once at creation and never touched again, so a
linked or bookmarked page URL never breaks even if the title changes later.
If a genuine slug change is ever needed, that's a direct database edit, not
something this endpoint supports.
Delete Page (Admin Only)
Endpoint: DELETE /api/v1/pages/{page_id}/
Response (204 No Content): Empty
Response (404 Not Found):
{
"error": "Page not found"
}
This is a genuine hard delete, unlike School/Department's soft-delete pattern, nothing else references a StaticPage by foreign key, and draft/publish already covers hiding a page without losing it.
API Clients (Future, Public API Phase)
Register API Client (Admin Only)
Endpoint: POST /api/v1/admin/api-clients/
Request:
{
"name": "ThirdPartyApp",
"scopes": ["schools:read", "questions:read"],
"rate_limit_tier": "STANDARD"
}
Response (201 Created):
{
"id": "uuid",
"name": "ThirdPartyApp",
"key_prefix": "ac_live_ab12",
"secret": "shown_only_once_here",
"scopes": ["schools:read", "questions:read"],
"rate_limit_tier": "STANDARD",
"created_at": "2026-01-01T00:00:00Z"
}
The secret is shown once at creation only, only a hash is stored.
Revoke API Client (Admin Only)
Endpoint: POST /api/v1/admin/api-clients/{client_id}/revoke/
Response (200 OK):
{ "message": "API client revoked", "revoked_at": "2026-01-01T00:00:00Z" }
Error Formats
400 Bad Request
{
"field_name": ["Error message"]
}
401 Unauthorized
{
"error": "Authentication credentials were not provided"
}
403 Forbidden
{
"error": "You do not have permission to perform this action"
}
404 Not Found
{
"error": "Resource not found"
}
405 Method Not Allowed
{
"error": "Method not allowed for this endpoint"
}
429 Too Many Requests
{
"error": "Rate limit exceeded. Please try again later."
}
500 Internal Server Error
{
"error": "An unexpected error occurred"
}
ID Format Validation
Every path parameter that identifies a resource (school, hub, question, answer, comment, notification, report, department, activation request, user) is validated as a well-formed UUID before any lookup happens. A malformed ID (wrong length, invalid characters, etc.) returns a 400, distinct from a well-formed ID that simply doesn't match anything, which still returns the resource's documented 404.
Response (400 Bad Request):
{
"error": "Invalid ID format"
}
Rate Limits
Rate limits are enforced, not just documented. Limits are tracked per authenticated user where the request carries a valid access token, and per IP address for anonymous requests.
| Endpoint | Limit |
|---|---|
| Authentication (login/refresh) | 10 requests per minute |
| Question creation | 30 requests per hour |
| Answer creation | 50 requests per hour |
| Comment creation | 50 requests per hour |
| Voting (vote and remove-vote combined) | 100 requests per hour |
| Search | 60 requests per minute |
| Reports | 10 requests per hour |
| General API (everything else) | 100 requests per minute |
The scoped limits above (authentication, question/answer/comment creation, voting, search, reports) apply in place of the general limit for that specific action, not in addition to it. Reading data (GET requests) is never subject to the tighter write-action limits, only the general 100 per minute baseline applies to reads.
Pagination
All list endpoints support pagination with the following parameters:
- page: Page number (default: 1)
- page_size: Items per page (default: 20, max: 100)
Response includes:
- count: Total number of items
- next: URL to next page (or null)
- previous: URL to previous page (or null)
- results: Array of items