# Student Verification — Integration Guide

> For the mobile developer. Read top-to-bottom. Every section is a step you'll
> implement.

Clustr is for school students. This feature gates most of the app until an
account is proven to belong to one — either a trusted school-email domain
(instant) or a document a human reviews (usually same-day). Call
`GET /api/student-verification/status` right after login/signup and route the
UI on its `stage` field.

**During onboarding**, "right after signup" means right after
`POST /api/onboarding/signup` — the account is created on the email + password
screen now, so a real `accessToken` exists before any onboarding screen runs.
Use it to drive this whole guide as an optional screen slotted in anywhere after
education. It's just as valid to skip it entirely during onboarding and do it
later from profile settings — every endpoint here works identically either way,
keyed only on the caller's `accessToken`.

- API base URL (local): `http://localhost:3001`
- All requests are JSON. Send `Content-Type: application/json`.
- Send `X-Client-Type: mobile` on every request.
- Every endpoint below requires `Authorization: Bearer <accessToken>`.

## 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-10-30** — **A verified student who moves school now verifies the new school before their education changes, and signup no longer verifies anyone.** New `POST` and `DELETE /api/student-verification/school-change`, new `/status` fields `isStudentVerified`, `purpose`, `schoolChange`; `GET /api/users/me/education` also returns `isStudentVerified` and `schoolChange`. A trusted school email now approves only when its school is the student's current education school — so verification always comes after the education screen. Education writes can return `409 SCHOOL_CHANGE_NEEDS_VERIFICATION` / `SCHOOL_CHANGE_IN_PROGRESS`; `/submit` refuses uploads older than 55 minutes (`409 DOCUMENT_EXPIRED`). Details: [School change](#school-change-a-verified-student-moves-school).
- **2026-10-30** — **A rejected student can now appeal to support, once per rejected document.** New `POST /api/student-verification/appeal { message }` and new `/status` fields `canRetry`, `canAppeal`, `appeal`. Show "Try again" only when `canRetry` and "Contact support" only when `canAppeal` — don't work the rules out yourself. While an appeal is open, `status` is `APPEALED` (new `stage: "APPEAL"`) and `/submit` returns `409 APPEAL_IN_PROGRESS`. Details: [Appeal](#appeal-contact-support).
- **2026-10-28** — **`POST /school-email` now refuses personal addresses** (Gmail, Yahoo, Outlook, their country versions, Apple relays, throwaway domains like `mailinator.com`) with `400 FREE_MAIL_NOT_ALLOWED`. Show the message and keep the user on the email screen, or let them skip to the document. Rows saved before this change are not cleaned up. It also refuses an address another account already uses — its login email, or a school email it already proved — with `409 SCHOOL_EMAIL_IN_USE`, before any code is sent (and again on `/confirm`).
- **2026-10-28** — `403 SHARING_REQUIRES_VERIFICATION` is gone. The "Share with others" toggle on activities and achievements is removed for everyone, so there is nothing left to gate. `shareToFeed` now gets `400 SHARE_TO_FEED_REMOVED` for every user. Details: [activities guide §2.5](../user-actions/mobile-activities-achievements-guide.md).
- **2026-10-27** — **`accountEmailPrefill` is `null` for more personal addresses.** The server now also treats country versions (`hotmail.co.uk`, `yahoo.co.in`, `outlook.in`), Apple "Hide My Email" relays (`privaterelay.appleid.com`), and common throwaway domains (`mailinator.com`) as personal mail. No shape change. Keep showing an empty, editable field when it is `null`.

---

## Short version — the endpoints

```
GET  /api/student-verification/status              → { isStudentVerified, purpose, status, stage, ... } — call on launch, and after any of the below
POST /api/student-verification/school-email         { email } → set the candidate, sends an OTP unless reused
POST /api/student-verification/school-email/confirm { code } → proves ownership. Does NOT approve.
POST /api/student-verification/complete             (no body) → asks for the decision. Safe to call unconditionally.
POST /api/student-verification/document/presign      { filename, contentType, size } → { key, uploadUrl, contentType }
     PUT <uploadUrl>                                 (file bytes, direct to storage)
POST /api/student-verification/submit                { documentKey, claimedSchoolName? } → PENDING, a human reviews it
POST /api/student-verification/appeal                { message } → APPEALED, support answers once
POST   /api/student-verification/school-change       { education entry } → verified student starts verifying a NEW school
DELETE /api/student-verification/school-change       (no body) → cancel an open school change, keep the old school
```

The same endpoints serve two **cases**: the first verification, and a later
school change. See [First verification vs school change](#first-verification-vs-school-change).

Two independent paths to get verified — a user may need only one:

1. **Email path** (instant, only if their school issues a recognised domain): set the email → confirm the OTP → call `/complete`.
2. **Document path** (always available, reviewed by a human, not instant): presign → upload → `/submit` → wait.

## `status` / `stage` — what screen to show

`GET /status` and every `403` a gated route returns carry the same two fields,
built by the same server-side function, so they can never disagree:

```jsonc
{
  "isStudentVerified": true,       // the gate is open. Use THIS for "is the user verified" — never `status`
  "purpose": "INITIAL",            // INITIAL | SCHOOL_CHANGE — which case status/stage describe
  "schoolChange": null,            // or { school, requestedAt, expiresAt, canCancel } — see School change
  "status": "NOT_STARTED",         // NOT_STARTED | PENDING | APPROVED | REJECTED | REVOKED | APPEALED
  "stage": "EMAIL",                 // which screen to open — see table below
  "documentRequired": true,
  "schoolEmail": "kid@school.edu.in",       // or null
  "schoolEmailVerifiedAt": "2026-...",       // or null — set once /confirm succeeds
  "accountEmail": "kid@school.edu.in",       // the account's own login email
  "accountEmailPrefill": "kid@school.edu.in", // or null — see below
  "verifiedSchool": { "id": "...", "name": "..." },  // or null
  "attempts": { "used": 1, "max": 3, "remaining": 2 }, // document submissions; a PENDING one counts
  "emailVerified": true,             // school email on file AND OTP-proven
  "canSkipEmail": true,              // emailVerified && attempts.remaining > 0 — retry goes straight to DOCUMENT
  "canRetry": true,                  // show "Try again"
  "canAppeal": true,                 // show "Contact support" (appeal)
  "rejection": { "reviewNote": "Photo is blurry", "reviewedAt": "2026-..." }, // null unless REJECTED or APPEALED
  "appeal": null,                    // or { "status": "OPEN" | "UPHELD", "openedAt": "2026-...", "supportNote": "..." | null }
  "gateActive": true
}
```

| `stage` | Screen | What it means |
| --- | --- | --- |
| `EMAIL` | School-email screen | Either no proof yet, or proof is on file and the button here calls `/complete` (trusted-domain student, one tap to finish) |
| `DOCUMENT` | Document upload screen | Email path isn't available (unmapped/untrusted domain, or the account has no usable school email) |
| `REVIEW` | "Under review" screen | Document submitted, `/submit` is refused with 409 while this lasts |
| `REJECTED` | Rejected screen, with `rejection.reviewNote`, `attempts`, and `appeal.supportNote` when present | "Try again" when `canRetry` → document screen (skip the email step when `canSkipEmail`). "Contact support" when `canAppeal` → appeal form. Both can be false |
| `APPEAL` | "Appeal under review" screen | `status: APPEALED`. No actions until support answers. `/submit` and `/appeal` are refused with 409 |
| `REVOKED` | Revoked screen | Withdrawn by support; contact-support copy, not a retry button |
| `DONE` | Nothing — app unlocked | `status: APPROVED` |

`stage` is a **recommendation, not a wall** — a school email is optional, so a
client may legitimately skip straight from `EMAIL` to `DOCUMENT` if the user
says they have no school email. The only stages the *server* actually
enforces are `REVIEW` (a second `/submit` is refused while one is pending) and
`DONE`.

## The three cases you'll see

1. **No school email at all.** `schoolEmail: null`. The user has no school
   address (or their account email is a free-mail domain like Gmail) — send
   them straight to the document screen. `documentRequired: true`.
2. **A school email on an unmapped or untrusted domain.** Set + confirmed,
   but nobody has told us that domain is student-only yet (or a human
   deliberately marked it mixed/unknown). `/complete` returns
   `{ approved: false, documentRequired: true }` and changes nothing —
   fall through to the document screen. This is normal, not an error.
3. **A school email on a trusted domain, for the student's own school.**
   `documentRequired: false` once confirmed. `/complete` approves
   immediately — `status` flips to `APPROVED`, the app unlocks, no document
   ever needed. "Own school" means the domain belongs to the school of the
   current education entry (or of the new school during a school change).
   A trusted address at any other school is treated like case 2. A student
   with no current education entry can't use the email path at all — so
   always run the education screen first.

## `accountEmailPrefill` — reuse, don't re-ask

If the account's own login email is already OTP-verified (from signup) and
isn't a free-mail domain (gmail/yahoo/outlook/…, their country versions like
`hotmail.co.uk`, Apple "Hide My Email" relays, common throwaway domains), `accountEmailPrefill` carries
that same address. **Prefill the school-email field with it — don't leave it
blank, and don't send a second OTP unprompted.** It's a hint, not a lock: the
field stays editable, and if the user changes it, that's a normal
`POST /school-email` call like any other (which sends a fresh OTP for the new
address).

`POST /school-email` also refuses an address another account already uses
(its login email, or a school email it proved): `409 SCHOOL_EMAIL_IN_USE`, no
code sent.

`POST /school-email` uses the same personal-mail list. A personal address gets
`400 FREE_MAIL_NOT_ALLOWED` and nothing is saved. So for any address saved from
2026-10-28 on, `emailVerified: true` means "a proven, non-personal address".
Older rows saved before that date may still hold a personal address.

**School-email screen — which field drives what:**

| Show | Field |
| --- | --- |
| Value in the email field | `schoolEmail`, else `accountEmailPrefill`, else empty |
| "Verified ✓" next to it | `emailVerified` |
| Open this screen at all | `stage === "EMAIL"` (`DOCUMENT` → go to the document screen) |

**Signup never verifies.** Until 2026-10-30, a signup with a trusted school
email was verified inside `POST /api/onboarding/signup`, before the education
screen. That is gone: verification always comes after education. For a
student whose signup email is on a trusted domain, the verification screen is
still one tap, with no second code:

1. `GET /status` → `stage: "EMAIL"`, `accountEmailPrefill` = the signup email.
2. `POST /school-email { email: accountEmailPrefill }` → the proof from signup
   is reused (`otpSent: false`).
3. `POST /complete` → `approved: true` when the domain's school is the school
   they just entered on the education screen.

## A confirmed code is not approval

`POST /school-email/confirm` only proves the user owns that mailbox — it never
changes `status`. **You must call `POST /complete` afterward** to actually ask
for the decision (safe to call every time, even speculatively — it's a no-op
read on an untrusted domain and idempotent on an already-approved account).
Don't infer approval from a successful confirm.

## Document upload

1. `POST /document/presign` with `{ filename, contentType, size }`.
   `contentType` must be one of: `image/jpeg`, `image/png`, `image/webp`,
   `image/heic`, `application/pdf`. `size` ≤ 10MB. Response:
   `{ key, uploadUrl, contentType }` — `uploadUrl` expires in 5 minutes.
2. `PUT` the raw file bytes to `uploadUrl` directly (not through the API).
3. `POST /submit` with `{ documentKey: key, claimedSchoolName? }` — round-trip
   the `key` from step 1 verbatim; you cannot construct your own. A mismatched
   or altered key is refused with `400 INVALID_DOCUMENT_KEY` before anything is
   touched. **Submit within 55 minutes of the presign.** An older key gets
   `409 DOCUMENT_EXPIRED`: run steps 1–3 again. So don't keep the `key` across
   an app restart. After a restart, upload again.
4. Response is the same shape as `/status`, now with `stage: "REVIEW"`. A
   human reviews it — usually same-day, no fixed SLA. Poll `/status` or wait
   for the `student_verification` push notification.

The uploaded file is never retrievable by the client. The server deletes it
on approval, when an appeal is answered, on the next `/submit`, or 14 days
after a rejection — whichever comes first. An upload that was never submitted
(the app closed between the PUT and `/submit`) is deleted after one hour.

## Appeal (Contact support)

A student who thinks a rejection is wrong can appeal it to support **once per
rejected document**. There is no deadline. Support answers once; there is no
chat and no in-app reply.

| Situation | `status` | `stage` | `canRetry` | `canAppeal` | `appeal` |
| --- | --- | --- | --- | --- | --- |
| Rejected, attempts left, not appealed yet | `REJECTED` | `REJECTED` | `true` | `true` | `null` |
| Rejected, no attempts left, not appealed yet | `REJECTED` | `REJECTED` | `false` | `true` | `null` |
| Appeal under review | `APPEALED` | `APPEAL` | `false` | `false` | `{ status: "OPEN", supportNote: null }` |
| Appeal declined, attempts left | `REJECTED` | `REJECTED` | `true` | `false` | `{ status: "UPHELD", supportNote }` |
| Appeal declined, no attempts left | `REJECTED` | `REJECTED` | `false` | `false` | `{ status: "UPHELD", supportNote }` |
| Appeal accepted | `APPROVED` | `DONE` | `false` | `false` | `null` |

Show a button only when its flag is `true`. If a later document is also
rejected, that rejection can be appealed too (`canAppeal` turns `true` again).

**The appeal form.** Show `rejection.reviewNote`, `rejection.reviewedAt` and
`attempts.used` of `attempts.max` read-only. The student types a message only.
Send:

```
POST /api/student-verification/appeal
{ "message": "The photo was clear, please look again." }   // 10–2000 characters
```

Don't send the reason or the attempts — the server copies them from its own
records. The response has the `/status` shape, now with `status: "APPEALED"`.
On any 409, refetch `/status` and route on it.

**The answer.** Support either accepts (the student is verified — the normal
*You're verified!* notification) or declines (`APPEAL_UPHELD` notification;
`appeal.supportNote` holds support's text — show it as-is). A declined appeal
keeps the attempt used.

## First verification vs school change

The same row, the same endpoints and the same screens serve two cases.
`purpose` tells you which one is running.

| | First verification | School change |
| --- | --- | --- |
| Who | A student who was never verified (or was revoked) | A verified student who moved to a new school |
| `purpose` | `INITIAL` | `SCHOOL_CHANGE` |
| `isStudentVerified` during the flow | `false` | **`true` the whole time** — reject and appeal don't change it |
| App gate during the flow | Locked (403 on gated routes) | **Open.** Nothing in the app locks |
| How it starts | Nothing to start — the first `/status` is `NOT_STARTED` | `POST /school-change` with the new school |
| Which school is checked | The current entry in `education` | `schoolChange.school` (not on the profile yet) |
| Education edits during the flow | Free | **All refused** (`409 SCHOOL_CHANGE_IN_PROGRESS`) |
| School email (instant approval) | Domain must belong to the current education school | Domain must belong to the **new** school. The old email is cleared on start |
| `accountEmailPrefill` | Account email, if institutional | Always `null` |
| On approval | Gate opens | Profile moves to the new school. The old school becomes a past entry |
| On rejection | Still locked; retry or appeal | Still verified, old school kept; retry, appeal or **cancel** |
| Cancel | Not possible | `DELETE /school-change` while `schoolChange.canCancel` |
| Ends by itself | Never | After 7 days if nothing was submitted |

**Three questions, three fields. Never mix them up:**

1. *Is this student verified?* → `isStudentVerified`. Not `status`. During a
   school change `status` can be `PENDING` or `REJECTED` while the student is
   fully verified.
2. *Which screen of the verification flow?* → `stage`, same table as above.
3. *Which flow is this?* → `purpose`. Use it for the screen titles and copy
   ("Verify your school" vs "Verify your new school").

## School change (a verified student moves school)

### When a school change is needed

Only for a verified student, and only when the **current school** changes.
Nobody is verified during the onboarding education screen (signup never
verifies), so onboarding never meets these rules. `GET /api/users/me/education`
returns two fields next to `data`, so the education screen knows the rules
without a second call:

| Field | Meaning |
| --- | --- |
| `isStudentVerified` | `true` (and no open change) → a current-school edit must go through `POST /school-change` |
| `schoolChange` | non-null → a change is open, **every** education edit is refused |

| Edit on the education screen | Verified, `schoolChange: null` | `schoolChange` non-null | Not verified |
| --- | --- | --- | --- |
| Grade, level, start/end year, order of the current entry | ✅ allowed | ❌ `SCHOOL_CHANGE_IN_PROGRESS` | ✅ |
| School, school name, city or country of the current entry | ❌ `SCHOOL_CHANGE_NEEDS_VERIFICATION` → start a school change | ❌ `SCHOOL_CHANGE_IN_PROGRESS` | ✅ |
| Add a new current entry (`isCurrent: true`), or make a past entry current | ❌ `SCHOOL_CHANGE_NEEDS_VERIFICATION` | ❌ `SCHOOL_CHANGE_IN_PROGRESS` | ✅ |
| Unset `isCurrent`, or delete the current entry | ❌ `SCHOOL_CHANGE_NEEDS_VERIFICATION` (see "left school" below) | ❌ `SCHOOL_CHANGE_IN_PROGRESS` | ✅ |
| Add, edit or delete a **past** entry | ✅ | ❌ `SCHOOL_CHANGE_IN_PROGRESS` | ✅ |
| `PUT` bulk save | Same rules: refused if the set of current schools changes | ❌ | ✅ |

The server enforces every row. Build the UI from the same rules so the student
never hits a 409:

- `schoolChange !== null` → show the education section read-only with a
  banner: *"Your new school is being verified."* and a link to the
  verification screen.
- `isStudentVerified: true` and `schoolChange: null` → the school/city/country
  fields of the current entry open the **school-change flow** instead of
  saving. Grade and the other fields save normally.

State (in India) is not a field on the entry. It comes from the school
catalog, so a different state always means a different `schoolId`.

### The flow

```
education screen ── "Change school" ──▶ pick the new school (school picker, same as onboarding)
     │
     ▼
POST /school-change { schoolId, schoolName, grade, city, country, … }
     │   status NOT_STARTED · purpose SCHOOL_CHANGE · isStudentVerified true
     ▼
stage EMAIL ──▶ /school-email (an address at the NEW school) → /confirm → /complete
stage DOCUMENT ──▶ presign → PUT → /submit           (same screens as the first verification)
     │
     ▼
stage REVIEW ──▶ approved ──▶ status APPROVED · purpose INITIAL · schoolChange null
     │                         education: new school current, old school a past entry
     └──▶ rejected ──▶ stage REJECTED: "Try again" (canRetry) · "Contact support" (canAppeal)
                                       · "Cancel school change" (schoolChange.canCancel)
```

1. **Start.** Send the new entry, the same shape as `POST /api/users/me/education`.
   `isCurrent` is set to `true` by the server. The response has the `/status`
   shape. Route on `stage` exactly like the first verification.
2. **Verify.** The same email or document screens. Change only the titles,
   based on `purpose`. The attempt budget restarts: a school change starts
   with 3 attempts.
3. **Wait.** `stage: REVIEW`. The app stays fully open.
4. **Result.** On approval, refetch `GET /api/users/me/education`. The new
   school is the current entry. The old school is still in the list as a past
   entry (`isCurrent: false`). A reviewer may correct the school (for example
   to the right catalog entry), so show what the server returns, not what the
   student typed.

### Response while a change is open

```jsonc
{
  "isStudentVerified": true,
  "purpose": "SCHOOL_CHANGE",
  "status": "NOT_STARTED",
  "stage": "DOCUMENT",
  "verifiedSchool": { "id": "...", "name": "Old School" },  // still the OLD school until approved
  "schoolChange": {
    "school": { "schoolId": "...", "schoolName": "New School", "grade": "9th", "city": "Pune", "isCurrent": true },
    "requestedAt": "2026-10-31T09:00:00.000Z",
    "expiresAt": "2026-11-07T09:00:00.000Z",   // only while NOT_STARTED; null once submitted
    "canCancel": true
  },
  // …all other /status fields as usual
}
```

### What to show, per situation

| Situation | `status` | `stage` | `canCancel` | Buttons | Education |
| --- | --- | --- | --- | --- | --- |
| Started, nothing sent yet | `NOT_STARTED` | `EMAIL` / `DOCUMENT` | `true` | Continue · Cancel | Locked |
| Under review | `PENDING` | `REVIEW` | `false` | none | Locked |
| Rejected, attempts left | `REJECTED` | `REJECTED` | `true` | Try again · Contact support (if `canAppeal`) · Cancel | Locked |
| Rejected, no attempts, appeal used | `REJECTED` | `REJECTED` | `true` | Cancel only | Locked |
| Appeal open | `APPEALED` | `APPEAL` | `false` | none | Locked |
| Approved | `APPROVED` | `DONE` | — (`schoolChange: null`) | — | New school, unlocked |
| Cancelled or expired | `APPROVED` | `DONE` | — (`schoolChange: null`) | — | Old school, unlocked |

In every row `isStudentVerified` is `true`. Never show the "verify to use
Clustr" wall during a school change.

### Leaving midway — every case

All state is on the server. The app stores nothing. On every launch, and every
time the verification or education screen opens, call `GET /status` (or
`GET /api/users/me/education`) and route on it.

| What the student does | What the server has | What to do |
| --- | --- | --- |
| Kills the app before `POST /school-change` | Nothing | Nothing |
| Kills the app after `POST /school-change` | An open change at `NOT_STARTED` | On reopen, `schoolChange` is non-null. Show the banner and continue from `stage` |
| Taps **back** on the email or document screen | Nothing changes. The change stays open | Ask: *"Keep your school change for later, or cancel it?"* Cancel → `DELETE /school-change` |
| Kills the app after the OTP was sent | The candidate email on the row | Reopen on `stage: EMAIL`. Offer "resend code" (`POST /school-email` again) |
| Kills the app after the PUT, before `/submit` | An uploaded file that no row points to | Upload again. The server deletes the stray file after one hour |
| Reopens later and sends an old `key` | — | `409 DOCUMENT_EXPIRED` → presign and upload again |
| Kills the app during `/submit` | Either nothing or `PENDING` — the server finishes the request on its own | Refetch `/status`. If `REVIEW`, the submit worked |
| Never comes back | `NOT_STARTED` for 7 days | The server cancels it at `expiresAt`. The old school stays. No notification |
| Gives up after a rejection | `REJECTED` | "Cancel school change" → `DELETE /school-change`. The old school stays |

A change at `PENDING` or `APPEALED` can't be cancelled and never expires. A
human decides it.

### Edge cases

- **Wrong school typed.** Before submitting: cancel, then start again with the
  right school. After submitting: wait for the review. The reviewer can
  correct the school.
- **Left school, no new school.** Not self-service. Unsetting `isCurrent` or
  deleting the current entry is refused. Point the student to support.
- **A second school change.** Allowed only after the first one closes
  (approved, cancelled or expired).
- **Old school email.** Starting a school change clears `schoolEmail`. An
  address at the old school never approves the new one. `/complete` returns
  `approved: false` for it, and `stage` shows `DOCUMENT`.
- **`ALREADY_VERIFIED` on `/submit`.** The student is verified and no change is
  open. A new document only goes in through `POST /school-change`.
- **Revoked during a school change.** The change is dropped. `isStudentVerified`
  turns `false`, `purpose` is `INITIAL`, and the revoke rules above apply.
- **Notifications.** A school-change approval or rejection uses its own copy
  (see [Outcome notification](#outcome-notification)). On tap, refetch `/status`
  and route on `stage` as usual.

## Outcome notification

Type `student_verification`, Activity surface (persisted row + push), under the
user's normal per-type preference. `data` is `{ status, decision }`, no
`deepLink` — on tap or on receipt, refetch `GET /status` and route on `stage`.

| `decision` | When | In-app (row) | Push |
| --- | --- | --- | --- |
| `APPROVED` | human approves a document, **or** `POST /complete` approves for the first time | **You're verified!** — Your student status is confirmed. You now have access to all Clustr features. | **You're verified!** — Your student status has been confirmed. You now have access to all Clustr features. |
| `REJECTED` | human rejects the document | **Verification needs attention!** — We could not verify your student status. Review your information and try again. | **Verification needs attention!** — Open Clustr to review your verification and try again. |
| `REVOKED` | support withdraws verification | Your student verification was withdrawn | same |
| `APPROVED` (school change) | the new school is approved | **Your new school is verified** — Your profile now shows your new school. | **Your new school is verified** — Your new school is verified. Your profile now shows it. |
| `REJECTED` (school change) | the new school is rejected | **We couldn't verify your new school** — Your profile still shows your previous school. Review the reason and try again, or cancel the change. | **We couldn't verify your new school** — Open Clustr to see why your new school couldn't be verified. |
| `APPEAL_UPHELD` | support declines an appeal | **Your appeal was reviewed** — Our support team answered your appeal. Open verification to see their note. | **Your appeal was reviewed** — Support answered your verification appeal. Open Clustr to see their note. |

An accepted appeal sends `APPROVED`.

A repeat `POST /complete` on an already-approved account sends nothing.

## The verified badge

Any payload that embeds a user (profiles, post/comment authors, feed, chat
participants and senders, follow lists, blocks, search, events, notification
actors, socket payloads) includes `isStudentVerified: boolean`. `true` = the
account passed student verification → show the badge. Same visibility as the
user object itself; the verification timestamp, school and method are never
exposed. It reflects state at response time: a revoked account reads `false`
on the next fetch, and a socket event carries the value as of emit. Your own
state (including pending/rejected) still comes from `/status`.

The same user objects also carry flat **`grade`** and **`school`** (both
`string | null`), taken from the user's **current** school (`isCurrent: true`,
lowest `order`). `null` = no current school on file. Render them as the
secondary line under the name ("10th · Delhi Public School"). **Only sent when you (the viewer) are
student-verified**: for an unverified viewer both keys are missing on every
user except their own, in REST and socket payloads. Treat a missing key as
"hidden", not "no school". The full `education` array is only on profiles
(`GET /api/users/me`, and `GET /api/users/:username` for verified viewers).

## What an unverified account can and can't do

**Works:** read the feed, like, save, share a post, read comments, view/edit
their own profile, add an activity or achievement (just not share it to the
feed), everything under this guide's own endpoints, auth, reporting, blocking,
password reset, account deletion, logout.

**Refused with `403 STUDENT_VERIFICATION_REQUIRED`:** creating a post,
commenting, following, chat (REST and realtime — a `join_conversation` socket
event is silently denied even if someone adds the user to a group), search,
discover-people, and anything else not explicitly ungated.

**Visible but locked (teasers):** `GET /api/chat/conversations?folder=requests`
(who wrote — only `id`/`username`/`displayName`/`avatarUrl` per user; `messages: []`, no text, no post-request details), `GET /api/follows/requests/incoming`,
and your own `GET /api/follows/<yourId>/followers`. Accepting/rejecting, replying, the
`folder=inbox` tray, and anyone else's follower list stay `403`.

**An unverified viewer sees a trimmed profile of anyone else** — `GET /api/users/:username`
returns `limited: true` and omits `bio`/`location`/`website`/`createdAt`/follower
counts. Render a reduced profile card when you see `limited: true`; don't treat
missing fields as empty ones. This never applies to the user's own profile
(`GET /api/users/me` is always full), and a verified viewer always sees the
full profile of anyone, including an unverified user — being unverified never
hides *your own* content from other people.

## Error codes, all of them

| Status | Code | Where |
| --- | --- | --- |
| 403 | `STUDENT_VERIFICATION_REQUIRED` | any gated route, unverified |
| 409 | `SCHOOL_EMAIL_IN_USE` | `/school-email` or `/school-email/confirm` — another account uses this address. No code sent. Ask for a different address or go to the document step |
| 400 | `FREE_MAIL_NOT_ALLOWED` | `/school-email` with a personal address (Gmail, Yahoo, `mailinator.com`, …). Nothing saved |
| 409 | `NO_SCHOOL_EMAIL` | `/school-email/confirm` called before `/school-email` |
| 409 | `REVIEW_IN_PROGRESS` | `/submit` while a decision is still pending |
| 409 | `ALREADY_VERIFIED` | `/submit` on an already-`APPROVED` account |
| 409 | `APPEAL_IN_PROGRESS` | `/submit` while an appeal is open (`status: APPEALED`) |
| 409 | `APPEAL_ALREADY_USED` | `/appeal` on a rejection that was already appealed |
| 409 | `NOT_APPEALABLE` | `/appeal` when status is not `REJECTED` |
| 503 | `DOCUMENT_REPLACE_FAILED` | `/submit` could not delete the previous kept document. Nothing changed; retry |
| 409 | `DOCUMENT_EXPIRED` | `/submit` with a key presigned more than 55 minutes ago. Upload again |
| 409 | `NOT_VERIFIED` | `POST /school-change` before the first verification is done. Edit education directly |
| 409 | `NOT_A_SCHOOL_CHANGE` | `POST /school-change` with the same school, city and country as now. Edit education directly |
| 409 | `SCHOOL_CHANGE_IN_PROGRESS` | `POST /school-change` while one is open, or **any** education write while one is open |
| 409 | `SCHOOL_CHANGE_NEEDS_VERIFICATION` | education write by a verified student that changes the current school. Start a school change instead |
| 409 | `NO_SCHOOL_CHANGE` | `DELETE /school-change` with nothing open |
| 409 | `REVIEW_IN_PROGRESS` / `APPEAL_IN_PROGRESS` | `DELETE /school-change` while the new school is under review or appealed. Wait for the decision |
| 403 | `VERIFICATION_ATTEMPTS_EXHAUSTED` | `/submit` after 3 rejected documents; body carries `attempts`. Support can grant more |
| 400 | `INVALID_OTP` / `MAX_ATTEMPTS_EXCEEDED` | `/school-email/confirm` |
| 400 | `INVALID_DOCUMENT_KEY` | `/submit` with a cross-user, traversal, or malformed key |
| 415 | `TYPE_MISMATCH` | uploaded bytes don't match an accepted image/PDF type |
| 429 | `RATE_LIMITED` | `/school-email` (5/hour), `/submit` (5/day), `/appeal` (5/hour), `/school-change` (10/hour) |

## Revocation

Support can withdraw a verification (`stage: "REVOKED"`). This is rare. A
revocation cannot be appealed (`canAppeal` is always `false`). The server does
accept a new document after a revoke, with a fresh 3 attempts, so follow
`canRetry` like on the rejected screen. The gate closes immediately for new requests; an already-open socket
connection can take up to 15 minutes to notice (bounded by normal token
expiry), so don't rely on an instant realtime signal for this specific case.
