Skip to main content
Version: Latest
UPDATED 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 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-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`.- **2026-10-13** — **Document verification is capped at 3 attempts, and `/status` now explains a decline.** New `/status` fields: `attempts { used, max, remaining }` (always), `rejection { reviewNote, reviewedAt }` (only while `REJECTED` — show the reviewer's note as-is), `emailVerified` and `canSkipEmail` (school email already proven → a retry goes straight to the document step, don't repeat the email screen). At `remaining: 0`, `POST /submit` returns `403 VERIFICATION_ATTEMPTS_EXHAUSTED` — show a contact-support screen. The decline push/in-app copy changed to *Verification needs attention!* (see Outcome notification).
## Short version — the seven endpoints## Short version — the endpoints ```GET /api/student-verification/status → { status, stage, ... } — call on launch, and after any of the belowGET /api/student-verification/status → { isStudentVerified, purpose, status, stage, ... } — call on launch, and after any of the belowPOST /api/student-verification/school-email { email } → set the candidate, sends an OTP unless reused
POST /api/student-verification/appeal { message } → APPEALED, support answers oncePOST /api/student-verification/school-change { education entry } → verified student starts verifying a NEW schoolDELETE /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 laterschool change. See [First verification vs school change](#first-verification-vs-school-change). Two independent paths to get verified — a user may need only one:
{ "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
fall through to the document screen. This is normal, not an error.3. **A school email on a trusted domain.** `documentRequired: false` once confirmed. `/complete` approves immediately — `status` flips to `APPROVED`, the app unlocks, no document ever needed.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.
If the signup email itself was on a trusted domain, the server may have**already verified the account during signup** — `POST /api/onboarding/signup`finishes verification in the same transaction when that's true. Such a user neversees `stage: EMAIL` or `DOCUMENT` at all; `GET /status` returns `DONE` from thefirst call after signup. See the onboarding guide's own `What's new`.**Signup never verifies.** Until 2026-10-30, a signup with a trusted schoolemail was verified inside `POST /api/onboarding/signup`, before the educationscreen. That is gone: verification always comes after education. For astudent whose signup email is on a trusted domain, the verification screen isstill 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.
or altered key is refused with `400 INVALID_DOCUMENT_KEY` before anything is touched. 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
on approval, when an appeal is answered, on the next `/submit`, or 14 daysafter a rejection — whichever comes first.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.
… change truncated — download the .md for the full update

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​

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

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:

{
"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
}
stageScreenWhat it means
EMAILSchool-email screenEither no proof yet, or proof is on file and the button here calls /complete (trusted-domain student, one tap to finish)
DOCUMENTDocument upload screenEmail path isn't available (unmapped/untrusted domain, or the account has no usable school email)
REVIEW"Under review" screenDocument submitted, /submit is refused with 409 while this lasts
REJECTEDRejected 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" screenstatus: APPEALED. No actions until support answers. /submit and /appeal are refused with 409
REVOKEDRevoked screenWithdrawn by support; contact-support copy, not a retry button
DONENothing — app unlockedstatus: 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:

ShowField
Value in the email fieldschoolEmail, else accountEmailPrefill, else empty
"Verified ✓" next to itemailVerified
Open this screen at allstage === "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.

SituationstatusstagecanRetrycanAppealappeal
Rejected, attempts left, not appealed yetREJECTEDREJECTEDtruetruenull
Rejected, no attempts left, not appealed yetREJECTEDREJECTEDfalsetruenull
Appeal under reviewAPPEALEDAPPEALfalsefalse{ status: "OPEN", supportNote: null }
Appeal declined, attempts leftREJECTEDREJECTEDtruefalse{ status: "UPHELD", supportNote }
Appeal declined, no attempts leftREJECTEDREJECTEDfalsefalse{ status: "UPHELD", supportNote }
Appeal acceptedAPPROVEDDONEfalsefalsenull

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 verificationSchool change
WhoA student who was never verified (or was revoked)A verified student who moved to a new school
purposeINITIALSCHOOL_CHANGE
isStudentVerified during the flowfalsetrue the whole time — reject and appeal don't change it
App gate during the flowLocked (403 on gated routes)Open. Nothing in the app locks
How it startsNothing to start — the first /status is NOT_STARTEDPOST /school-change with the new school
Which school is checkedThe current entry in educationschoolChange.school (not on the profile yet)
Education edits during the flowFreeAll refused (409 SCHOOL_CHANGE_IN_PROGRESS)
School email (instant approval)Domain must belong to the current education schoolDomain must belong to the new school. The old email is cleared on start
accountEmailPrefillAccount email, if institutionalAlways null
On approvalGate opensProfile moves to the new school. The old school becomes a past entry
On rejectionStill locked; retry or appealStill verified, old school kept; retry, appeal or cancel
CancelNot possibleDELETE /school-change while schoolChange.canCancel
Ends by itselfNeverAfter 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:

FieldMeaning
isStudentVerifiedtrue (and no open change) → a current-school edit must go through POST /school-change
schoolChangenon-null → a change is open, every education edit is refused
Edit on the education screenVerified, schoolChange: nullschoolChange non-nullNot 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 saveSame 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​

{
"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​

SituationstatusstagecanCancelButtonsEducation
Started, nothing sent yetNOT_STARTEDEMAIL / DOCUMENTtrueContinue · CancelLocked
Under reviewPENDINGREVIEWfalsenoneLocked
Rejected, attempts leftREJECTEDREJECTEDtrueTry again · Contact support (if canAppeal) · CancelLocked
Rejected, no attempts, appeal usedREJECTEDREJECTEDtrueCancel onlyLocked
Appeal openAPPEALEDAPPEALfalsenoneLocked
ApprovedAPPROVEDDONE— (schoolChange: null)—New school, unlocked
Cancelled or expiredAPPROVEDDONE— (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 doesWhat the server hasWhat to do
Kills the app before POST /school-changeNothingNothing
Kills the app after POST /school-changeAn open change at NOT_STARTEDOn reopen, schoolChange is non-null. Show the banner and continue from stage
Taps back on the email or document screenNothing changes. The change stays openAsk: "Keep your school change for later, or cancel it?" Cancel → DELETE /school-change
Kills the app after the OTP was sentThe candidate email on the rowReopen on stage: EMAIL. Offer "resend code" (POST /school-email again)
Kills the app after the PUT, before /submitAn uploaded file that no row points toUpload 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 /submitEither nothing or PENDING — the server finishes the request on its ownRefetch /status. If REVIEW, the submit worked
Never comes backNOT_STARTED for 7 daysThe server cancels it at expiresAt. The old school stays. No notification
Gives up after a rejectionREJECTED"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). 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.

decisionWhenIn-app (row)Push
APPROVEDhuman approves a document, or POST /complete approves for the first timeYou'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.
REJECTEDhuman rejects the documentVerification 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.
REVOKEDsupport withdraws verificationYour student verification was withdrawnsame
APPROVED (school change)the new school is approvedYour 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 rejectedWe 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_UPHELDsupport declines an appealYour 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​

StatusCodeWhere
403STUDENT_VERIFICATION_REQUIREDany gated route, unverified
409SCHOOL_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
400FREE_MAIL_NOT_ALLOWED/school-email with a personal address (Gmail, Yahoo, mailinator.com, …). Nothing saved
409NO_SCHOOL_EMAIL/school-email/confirm called before /school-email
409REVIEW_IN_PROGRESS/submit while a decision is still pending
409ALREADY_VERIFIED/submit on an already-APPROVED account
409APPEAL_IN_PROGRESS/submit while an appeal is open (status: APPEALED)
409APPEAL_ALREADY_USED/appeal on a rejection that was already appealed
409NOT_APPEALABLE/appeal when status is not REJECTED
503DOCUMENT_REPLACE_FAILED/submit could not delete the previous kept document. Nothing changed; retry
409DOCUMENT_EXPIRED/submit with a key presigned more than 55 minutes ago. Upload again
409NOT_VERIFIEDPOST /school-change before the first verification is done. Edit education directly
409NOT_A_SCHOOL_CHANGEPOST /school-change with the same school, city and country as now. Edit education directly
409SCHOOL_CHANGE_IN_PROGRESSPOST /school-change while one is open, or any education write while one is open
409SCHOOL_CHANGE_NEEDS_VERIFICATIONeducation write by a verified student that changes the current school. Start a school change instead
409NO_SCHOOL_CHANGEDELETE /school-change with nothing open
409REVIEW_IN_PROGRESS / APPEAL_IN_PROGRESSDELETE /school-change while the new school is under review or appealed. Wait for the decision
403VERIFICATION_ATTEMPTS_EXHAUSTED/submit after 3 rejected documents; body carries attempts. Support can grant more
400INVALID_OTP / MAX_ATTEMPTS_EXCEEDED/school-email/confirm
400INVALID_DOCUMENT_KEY/submit with a cross-user, traversal, or malformed key
415TYPE_MISMATCHuploaded bytes don't match an accepted image/PDF type
429RATE_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.

⤓ Download .md