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:

Terminal
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:

HTTP
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:

HTTP
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:

HTTP
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.

JSON
{
  "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"
}
  • action names the screen to open, so the app routes on the payload rather than re-deriving where each type belongs.
  • tone is info | 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_cursor back as ?before=.
  • Filter with ?type=fee,result and ?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:

HTTP
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:

  1. The amount is never taken from the client. The server reads the invoice and charges its outstanding balance; part_amount_cents can only ever pay less.
  2. The ledger is untouched until /verify. An abandoned payment leaves the invoice exactly as it was.
  3. /verify is 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

HTTP
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:

JSON
{ "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_threads exists 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.