API Documentation
SkoolyHub exposes a REST API for the parent, teacher and student apps under /api/v1. The admin panel uses its own endpoints under /api/v1/admin, which are deliberately excluded from the app-facing reference.
The live reference
/api/docs/v1 renders a Scalar reference generated from the route handlers themselves, so it cannot drift from the code. It is admin-gated: the page redirects to the admin login and the spec returns 401 without a session.
Regenerate after adding or renaming routes:
npm run gen:openapi
The generator scans every src/app/api/**/route.ts, derives the path, the methods actually exported, and the auth requirement from which helper the file calls.
Authentication
Each portal has its own login returning a 30-day JWT:
POST /api/v1/parent/auth/login
{ "handle": "parent@example.com", "password": "…" }
handle accepts email or phone for guardians, email for staff, and admission number or email for students. Mobile clients send the token back as:
Authorization: Bearer <jwt>
The web receives the same session as an httpOnly cookie (parent_session, staff_session, student_session).
The token carries its role. A parent token replayed against a teacher endpoint is a 401, not a partial success — the role is checked before any scoping happens.
Guardian activation and reset
Guardians never self-register. The school creates the record; the parent activates it:
POST /api/v1/parent/auth/request-otp { "handle": "…" }
POST /api/v1/parent/auth/verify-otp { "handle": "…", "code": "123456" }
POST /api/v1/parent/auth/set-password { "handle": "…", "code": "123456", "password": "…" }
The same three calls serve password reset. Teachers have the identical flow at /api/v1/teacher/auth/*.
request-otp always returns 200, known handle or not. A 404 would turn it into a membership oracle — "is this phone number a parent at this school" is exactly the question the endpoint must not answer.
Scoping
Every parent endpoint resolves the caller's children and scopes to them. GET /api/v1/parent/children is the only source of student ids; a public_id from anywhere else 404s. Pass one as ?student= to narrow to a single child.
Teacher endpoints scope to the sections that teacher covers. Ids in URLs are opaque public_id values.
The parent home feed
GET /api/v1/parent/feed is the app's home screen: one chronological stream merging announcements, attendance exceptions, homework set and graded, exam schedules, published results, invoices and receipts, leave decisions and calendar entries.
{
"data": [
{
"id": "fee:c2aeade10470b4156832c2c4",
"type": "fee",
"at": "2026-08-17T18:47:12.000Z",
"title": "Invoice INV2026-0002 — ₹5,750.00",
"body": "₹5,750.00 outstanding, due 16 Sept.",
"tone": "warning",
"student": { "public_id": "…", "name": "Noor Ahmed", "photo": null },
"action": { "screen": "invoice", "id": "…", "href": "/parent/fees/…" },
"meta": { "balance_cents": 575000, "due_date": "2026-09-16" }
}
],
"next_cursor": "2026-08-17T18:47:12.000Z"
}
actionnames the screen to open, so the app routes on the payload rather than re-deriving where each type belongs.toneisinfo | success | warning | urgent— map it to your own palette.- Paging is a timestamp cursor, not a page number: the stream is merged from a dozen sources, so an offset would shift the moment the school published anything and the app would show a duplicate card. Send
next_cursorback as?before=. - Filter with
?type=fee,resultand?student=<public_id>;?limit=is 1–50. - Attendance appears only as an exception. A card saying a child was present every single day is a feed parents stop reading.
Paying fees
Which gateway runs is add-on configuration, not code. Ask what is available, start the payment, then confirm it:
GET /api/v1/parent/payment-methods
POST /api/v1/parent/fees/{id}/pay { "provider": "razorpay" }
POST /api/v1/parent/fees/{id}/verify { "attempt": "…", "payload": { … } }
/pay responds with a mode telling the app what to do next:
mode |
The app… |
|---|---|
redirect |
opens redirect_url, then calls /verify on return |
modal |
opens the gateway widget with modal, then calls /verify |
form_post |
auto-submits fields to action_url, then calls /verify |
offline |
shows instructions; the office confirms. Nothing to verify |
recorded |
no provider was sent — already on the ledger, receipt included |
Three properties matter:
- The amount is never taken from the client. The server reads the invoice and charges its outstanding balance;
part_amount_centscan only ever pay less. - The ledger is untouched until
/verify. An abandoned payment leaves the invoice exactly as it was. /verifyis safe to call twice — a repeat returns the original receipt rather than crediting again, which matters because an app that verifies on return and a gateway that retries its webhook both arrive here.
An empty payment-methods list is a legitimate answer: plenty of schools take fees at the office only, and the app should say so rather than show a button that cannot work.
Taking a register
POST /api/v1/teacher/attendance
{
"section_id": 12,
"date": "2026-08-20",
"default_status": "present",
"marks": [{ "student_id": 41, "status": "absent", "remarks": "Unwell" }]
}
default_status applies to the whole roll and marks becomes the exception list — a class of forty with two absences is one small request rather than forty objects. Listing every student explicitly still works unchanged.
Re-taking a register overwrites rather than duplicating, so a latecomer can be corrected minutes later. A day-register absence notifies guardians; a per-period absence does not.
Response shapes
Collections that carry totals:
{ "data": [ … ], "meta": { "total": 128, "page": 1, "per_page": 20, "total_pages": 7 } }
Detail endpoints return the record, sometimes alongside its relations ({ "student": …, "guardians": […] }). Errors are { "error": "A human-readable message." } with a meaningful status — 400 for expected failures, 401 unauthenticated, 404 not found or not yours, 429 rate-limited.
Conventions worth knowing
- Money is minor units everywhere, with the currency alongside it.
- Dates are
YYYY-MM-DD; timestamps are ISO 8601 UTC. - Days of week are Monday-first: 0 = Monday … 6 = Sunday.
- Attendance percentage divides by recorded days, not calendar days — a register the school never took counts against nobody.
- Auth and public write endpoints are rate-limited, per IP and per handle.
Gaps
Two things the apps may want that the API does not yet expose:
- Parent–teacher messaging.
message_threadsexists and the admin uses it, but no parent or teacher route reads or writes it. - A payment webhook endpoint. Verification is app-initiated today. A gateway that only calls back server-to-server needs a webhook route that resolves the attempt by its gateway reference and settles it.