Skip to main content
Version: Latest
UPDATED 2026-09-07 POST /api/schools still rejects an inappropriate school name with 422 SCHOOL_NAME_BLOCKED — unchanged — but the same check now also covers state, district and the q on school search, and the 422 carries a field naming which input tripped. Same filter, now app-wide: [mobile-content-moderation-guide.md](../moderation/mobile-content-moderation-guide.md).
- **2026-09-07** — `POST /api/schools` still rejects an inappropriate school name with 422 `SCHOOL_NAME_BLOCKED` — unchanged — but the same check now also covers `state`, `district` and the `q` on school search, and the 422 carries a `field` naming which input tripped. Same filter, now app-wide: [`mobile-content-moderation-guide.md`](../moderation/mobile-content-moderation-guide.md).- **2026-09-04** — All 36 states and union territories now appear in the state dropdown. Punjab, Haryana, Delhi, Uttarakhand, Sikkim, Arunachal Pradesh and five more were missing entirely. Eleven of them have no schools in our catalog yet — `/districts` tells you which with `meta.catalogEmpty`, and the user adds their own school with `POST /api/schools` (section 4). A school added that way works immediately as `schoolId`, but stays out of search results until we review it.

Mobile Schools — Integration Guide

For mobile development. Everything needed to power the Education step school picker — state + district dropdowns and the school-name typeahead. No backend changes required.

  • API base URL (local): http://localhost:3001
  • Auth: either an onboardingToken (during signup) OR an accessToken (after login) — both work on every endpoint below. Send as Authorization: Bearer <token>.
  • Send X-Client-Type: mobile on every request.

What's new

  • 2026-09-07POST /api/schools still rejects an inappropriate school name with 422 SCHOOL_NAME_BLOCKED — unchanged — but the same check now also covers state, district and the q on school search, and the 422 carries a field naming which input tripped. Same filter, now app-wide: mobile-content-moderation-guide.md.
  • 2026-09-04 — All 36 states and union territories now appear in the state dropdown. Punjab, Haryana, Delhi, Uttarakhand, Sikkim, Arunachal Pradesh and five more were missing entirely. Eleven of them have no schools in our catalog yet — /districts tells you which with meta.catalogEmpty, and the user adds their own school with POST /api/schools (section 4). A school added that way works immediately as schoolId, but stays out of search results until we review it.
  • 2026-09-04 — "Tamilnadu" now reads "Tamil Nadu" in every response. You can send either spelling back; both work.
  • 2026-09-04 — The typeahead section was wrong and is now corrected. q is required with a 3-character minimum (not 2), a missing q returns 200 with meta.needsQuery (there is no MISSING_FILTER code), pinCode narrows a search instead of being one, and districts are UPPERCASE and matched exactly — the old district=Pune example returned nothing.

Short version — just the calls

GET /api/schools/states → list of states
GET /api/schools/districts?state=Maharashtra → districts for that state
GET /api/schools?q=delhi&state=&district=&pinCode=&limit=20
→ school typeahead
GET /api/schools/:id → re-hydrate a previously picked school
POST /api/schools → add a school the catalog is missing

Read this before building the picker: the catalog covers 25 of India's 36 states/UTs. All 36 are offered by /states, but in Arunachal Pradesh, Chandigarh, Dadra & Nagar Haveli and Daman & Diu, Delhi, Haryana, Himachal Pradesh, Jammu & Kashmir, Ladakh, Punjab, Sikkim and Uttarakhand every search returns zero results — there is nothing seeded to find. /districts tells you which state you are in (meta.catalogEmpty), and section 4 is how the user gets past it. A picker without the "add your school" path strands those users.

Pick any combination — user can drill down state → district → school, or skip both upper dropdowns and search the school name directly. Either path returns the same school payload (with state + district embedded), so the upper dropdowns can be back-filled from the selection. q (≥ 3 chars) is what returns schools; state / district / pinCode only narrow it.


1 — States dropdown

1.1 — Endpoint

GET /api/schools/states
Authorization: Bearer <onboardingToken | accessToken>

Rate limit: 30 requests / 60 seconds per user. Cache: Cache-Control: public, max-age=3600 — cache aggressively client-side.

Response:

{
"success": true,
"data": [
"Andaman & Nicobar Islands",
"Andhra Pradesh",
"Arunachal Pradesh",
"Assam",
"Bihar",
"...",
],
}

Strings are case-preserved as they appear in the catalog. Pass them back verbatim to /api/schools and /api/schools/districts (server still accepts case-insensitively, but verbatim matches the typeahead labels).

1.2 — Display aliases

A few catalog strings are spelled the way UDISE spells them, not the way a user reads them. The API serves the display label and accepts it back on every state / district param — no client-side mapping needed:

Catalog rowsWhat you get / send
TamilnaduTamil Nadu
MUMBAI II + MUMBAI (SUBURBAN)Mumbai
BENGALURU U NORTH + BENGALURU U SOUTHBengaluru
IMPHAL EAST + IMPHAL WESTImphal
WARANGAL URBAN + WARANGAL RURALWarangal

The label is what every school object carries in its state field too, so copying picked.state straight into the state picker (section 3.3) is safe. The raw catalog strings still work as filters — old clients don't break.


2 — Districts dropdown

2.1 — Endpoint

GET /api/schools/districts?state=Maharashtra
Authorization: Bearer <token>

Rate limit: 60 / 60s. Cache: Cache-Control: public, max-age=3600.

ParamTypeRequiredNotes
statestringyes1–80 chars. Case-insensitive equality match.

400 if state missing: { "code": "MISSING_STATE" }.

Response:

{
"success": true,
"data": [
"AHMADNAGAR",
"AKOLA",
"AMRAVATI",
"AURANGABAD (MAHARASHTRA)",
"...",
],
}

The response also carries meta.catalogEmpty. When it is true the state has no seeded schools at all, so skip straight to the "Can't find your school? Add it" flow (section 4) instead of making the user type into a search that cannot match:

{
"success": true,
"data": ["AMRITSAR", "BARNALA", "BATHINDA", "..."],
"meta": { "catalogEmpty": true },
}

Districts come back UPPERCASE — that is the catalog's own casing, and /api/schools?district= matches it exactly (district=Pune returns nothing, district=PUNE works). Send back what this endpoint gave you, verbatim; title-case it in the UI layer only. 404 STATE_NOT_FOUND if state isn't a known state.


3 — School name typeahead

3.1 — Endpoint

GET /api/schools?q=delhi&state=&district=&pinCode=&limit=20
Authorization: Bearer <token>

Rate limit: 60 / 60s. Cache: Cache-Control: public, max-age=300 — short, per-query.

ParamTypeRequiredNotes
qstringyes≥ 3 chars. Case-insensitive substring match on school name (contains). Nothing is returned without it — see below.
statestringnoExact equality against the catalog string; display aliases (§1.2) are mapped for you.
districtstringnoExact equality; a grouped label (Mumbai) expands to all its catalog districts.
pinCodestringnoExact match on PIN string. A narrowing filter on top of q, not a search on its own.
limitintno1–50, default 20.

q drives the search; state / district / pinCode only narrow it. A request without q is not an error — it returns 200 with an empty list and a meta block, so render your "type to search" placeholder from it rather than dumping a district (PUNE alone is 7,456 schools):

{
"success": true,
"data": [],
"meta": {
"needsQuery": true,
"minQueryLength": 3,
"message": "Type at least 3 letters to search schools",
},
}

q present but shorter than 3 chars → 400 QUERY_TOO_SHORT. Three is a hard floor, not a UX preference: the search runs on a trigram index, and a 2-char q matches no trigram and degrades to a full scan of ~1.5M rows. Gate the request client-side at 3 characters and debounce.

Response:

{
"success": true,
"data": [
{
"id": "01996c4a-...",
"udiseCode": "27130100102",
"name": "DELHI PUBLIC SCHOOL",
"state": "Maharashtra",
"district": "PUNE",
"block": "KALEPADAL MOHAMMADWADI",
"management": "Private Unaided (Recognized)",
"category": "Pri. with Upper Pri. Sec. and H.Sec.",
"pinCode": "411060",
},
],
}

Sorted alphabetically by name, tie-break by id. name / district / block are UDISE's own strings — mostly uppercase, sometimes abbreviated. Render them as-is; don't try to "fix" them client-side or they stop matching what you send back.

3.2 — All the ways to call it

The three flows the UI supports — pick any:

Every one of them needs q (≥ 3 chars) — the filters only narrow it.

A. Drill down (state → district → school)

GET /api/schools/states
GET /api/schools/districts?state=Maharashtra
GET /api/schools?state=Maharashtra&district=PUNE&q=delhi&limit=20

Most narrow / fastest. State + district filters use the (state, district) btree index. Pass both strings back exactly as /states and /districts served them.

B. Skip dropdowns, search by name

GET /api/schools?q=delhi&limit=20

Matches across all of India. Slower than A, still fine — the trigram index serves it — but expect commoner names to fill the 20-row window.

C. Pin code narrowing

GET /api/schools?pinCode=411014&q=delhi&limit=20

Useful when the user knows their PIN. pinCode alone returns the needsQuery placeholder — it narrows q, it does not replace it.

3.3 — Auto-populating upper dropdowns from a school pick

Every school object carries state + district. When the user picks a school via path B / C (without choosing state + district first), read those fields off the selected row and write them into the state + district pickers:

const picked = result.data[i];
onboardingDraft.education.state = picked.state;
onboardingDraft.education.district = picked.district;
onboardingDraft.education.schoolId = picked.id;
onboardingDraft.education.schoolName = picked.name; // free-text fallback

picked.state is already the display label (§1.2). picked.district is the raw catalog string, so for the four grouped metros it won't equal any entry in your district dropdown (MUMBAI (SUBURBAN) vs the Mumbai option) — map it through the §1.2 table before selecting the dropdown row. As a filter the raw string is still accepted.


4 — Add a school that isn't in the catalog

When search comes up empty — always, in the eleven states above — offer "Can't find your school? Add it".

POST /api/schools
Authorization: Bearer <onboardingToken | accessToken>
Content-Type: application/json

{
"name": "St Xavier High School",
"state": "Punjab",
"district": "LUDHIANA",
"pinCode": "141001"
}

Rate limit: 5 / 3600s per user.

FieldTypeRequiredNotes
namestringyes3–120 chars, trimmed before validation.
statestringyesExactly as /states served it (aliases accepted).
districtstringyesExactly as /districts served it (a grouped label works too).
pinCodestringno6 digits.
blockstringno≤ 80 chars.

Response — 201:

{
"success": true,
"data": {
"id": "01996c4a-...",
"name": "St Xavier High School",
"state": "Punjab",
"district": "LUDHIANA",
"status": "PENDING",
"udiseCode": null,
},
"meta": { "existing": false },
}

4.1 — What PENDING means for the UI

  • Use the id straight away. Send schoolId + schoolName on the Education object exactly as you would for a catalog pick. Nothing is blocked.
  • It will not appear in search — not even for the user who added it — until a human approves it. Don't re-query /api/schools to confirm the add "worked"; the 201 is the confirmation.
  • GET /api/schools/:id does return it, with status: "PENDING". That is how you re-render a saved draft. Badge it as "waiting for review" if you want; once approved the same id starts returning status: "APPROVED".
  • Every school object now carries status. Catalog schools are always "APPROVED".

4.2 — Submitting something that already exists

Not an error. You get 200 (not 201) with meta.existing: true and the row that already existed — same shape, so treat both the same way. A double-tap on "Add" is harmless, and a user who couldn't find a school that is in the catalog gets pointed at the real one.

4.3 — Errors specific to this endpoint

HTTPcodeMeaning
422SCHOOL_NAME_BLOCKEDThe name tripped the profanity filter. Show the message as-is.
422SCHOOL_REJECTEDThis exact school was reviewed and turned down. Don't retry it.
422TOO_MANY_PENDING_SCHOOLS5 of this user's submissions are still awaiting review.
404STATE_NOT_FOUND / DISTRICT_NOT_FOUNDThe state/district wasn't one we served. Send ours verbatim.
4295 submissions/hour exhausted.

5 — Re-hydrate a school by id

When restoring saved onboarding state (mobile sent schoolId previously and wants to render the picker pre-filled):

GET /api/schools/01996c4a-...
Authorization: Bearer <token>

Same school object shape as section 3, wrapped as { success, data }. 404 if the id is unknown.


6 — Edge cases / error codes

HTTPcodeMeaning
400QUERY_TOO_SHORTq < 3 chars. (q absent is not an error — 200 + meta.needsQuery.)
400MISSING_STATE/api/schools/districts called without state.
404STATE_NOT_FOUND/api/schools/districts called with a state not in the catalog.
401Token missing / invalid / expired.
404/api/schools/:id — unknown id.
429Rate limit hit. Back off using Retry-After header (seconds).

POST /api/schools has its own codes — see section 4.3.


7 — Sending the picked school back to onboarding

Persist both an opaque schoolId (FK candidate, picked from this catalog) and the human-readable schoolName on the Education object you PATCH to /api/onboarding/session:

PATCH /api/onboarding/session
{
"data": {
"education": [{
"schoolId": "01996c4a-...", // null if user typed a custom name
"schoolName": "Delhi Public School Pune",
"level": "high",
"grade": "10th",
"isCurrent": true,
"city": "Pune",
"country": "India"
}]
},
"checkpoint": "education"
}

Both schoolId and schoolName are persisted on the Education row:

  • schoolId — canonical FK into the School catalog (UDISE). Required if the user picked from the typeahead.
  • schoolName — human-readable label, used for rendering on profile etc. Always required.

When the user typed a school not in the catalog, omit schoolId and send only schoolName. The same shape is accepted by the standalone POST/PUT/PATCH /api/users/me/education endpoints for post-signup edits.


8 — Quick reference cheat sheet

States dropdown → GET /api/schools/states
Districts dropdown → GET /api/schools/districts?state=<state>
School typeahead → GET /api/schools?q=<3+chars>&state=&district=&pinCode=&limit=<1-50>
Add a school → POST /api/schools {name,state,district[,pinCode,block]}
School re-hydrate → GET /api/schools/:id
Auth header → Authorization: Bearer <onboardingToken | accessToken>
⤓ Download .md