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).
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 anaccessToken(after login) — both work on every endpoint below. Send asAuthorization: Bearer <token>. - Send
X-Client-Type: mobileon every request.
What's new
- 2026-09-07 —
POST /api/schoolsstill rejects an inappropriate school name with 422SCHOOL_NAME_BLOCKED— unchanged — but the same check now also coversstate,districtand theqon school search, and the 422 carries afieldnaming 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 —
/districtstells you which withmeta.catalogEmpty, and the user adds their own school withPOST /api/schools(section 4). A school added that way works immediately asschoolId, 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.
qis required with a 3-character minimum (not 2), a missingqreturns 200 withmeta.needsQuery(there is noMISSING_FILTERcode),pinCodenarrows a search instead of being one, and districts are UPPERCASE and matched exactly — the olddistrict=Puneexample 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 rows | What you get / send |
|---|---|
Tamilnadu | Tamil Nadu |
MUMBAI II + MUMBAI (SUBURBAN) | Mumbai |
BENGALURU U NORTH + BENGALURU U SOUTH | Bengaluru |
IMPHAL EAST + IMPHAL WEST | Imphal |
WARANGAL URBAN + WARANGAL RURAL | Warangal |
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.
| Param | Type | Required | Notes |
|---|---|---|---|
state | string | yes | 1–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.
| Param | Type | Required | Notes |
|---|---|---|---|
q | string | yes | ≥ 3 chars. Case-insensitive substring match on school name (contains). Nothing is returned without it — see below. |
state | string | no | Exact equality against the catalog string; display aliases (§1.2) are mapped for you. |
district | string | no | Exact equality; a grouped label (Mumbai) expands to all its catalog districts. |
pinCode | string | no | Exact match on PIN string. A narrowing filter on top of q, not a search on its own. |
limit | int | no | 1–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.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 3–120 chars, trimmed before validation. |
state | string | yes | Exactly as /states served it (aliases accepted). |
district | string | yes | Exactly as /districts served it (a grouped label works too). |
pinCode | string | no | 6 digits. |
block | string | no | ≤ 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+schoolNameon 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/schoolsto confirm the add "worked"; the201is the confirmation. GET /api/schools/:iddoes return it, withstatus: "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 returningstatus: "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
| HTTP | code | Meaning |
|---|---|---|
| 422 | SCHOOL_NAME_BLOCKED | The name tripped the profanity filter. Show the message as-is. |
| 422 | SCHOOL_REJECTED | This exact school was reviewed and turned down. Don't retry it. |
| 422 | TOO_MANY_PENDING_SCHOOLS | 5 of this user's submissions are still awaiting review. |
| 404 | STATE_NOT_FOUND / DISTRICT_NOT_FOUND | The state/district wasn't one we served. Send ours verbatim. |
| 429 | — | 5 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
| HTTP | code | Meaning |
|---|---|---|
| 400 | QUERY_TOO_SHORT | q < 3 chars. (q absent is not an error — 200 + meta.needsQuery.) |
| 400 | MISSING_STATE | /api/schools/districts called without state. |
| 404 | STATE_NOT_FOUND | /api/schools/districts called with a state not in the catalog. |
| 401 | — | Token missing / invalid / expired. |
| 404 | — | /api/schools/:id — unknown id. |
| 429 | — | Rate 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>