# 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

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

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

```http
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:**

```jsonc
{
  "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

```http
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:**

```jsonc
{
  "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:

```jsonc
{
  "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

```http
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):

```jsonc
{
  "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:**

```jsonc
{
  "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:

```ts
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".

```http
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`:**

```jsonc
{
  "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

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

```http
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`:

```jsonc
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>
```
