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).
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: mobileon 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
POSTandDELETE /api/student-verification/school-change, new/statusfieldsisStudentVerified,purpose,schoolChange;GET /api/users/me/educationalso returnsisStudentVerifiedandschoolChange. 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 return409 SCHOOL_CHANGE_NEEDS_VERIFICATION/SCHOOL_CHANGE_IN_PROGRESS;/submitrefuses 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/statusfieldscanRetry,canAppeal,appeal. Show "Try again" only whencanRetryand "Contact support" only whencanAppeal— don't work the rules out yourself. While an appeal is open,statusisAPPEALED(newstage: "APPEAL") and/submitreturns409 APPEAL_IN_PROGRESS. Details: Appeal. - 2026-10-28 —
POST /school-emailnow refuses personal addresses (Gmail, Yahoo, Outlook, their country versions, Apple relays, throwaway domains likemailinator.com) with400 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 — with409 SCHOOL_EMAIL_IN_USE, before any code is sent (and again on/confirm). - 2026-10-28 —
403 SHARING_REQUIRES_VERIFICATIONis gone. The "Share with others" toggle on activities and achievements is removed for everyone, so there is nothing left to gate.shareToFeednow gets400 SHARE_TO_FEED_REMOVEDfor every user. Details: activities guide §2.5. - 2026-10-27 —
accountEmailPrefillisnullfor 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 isnull.
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:
- Email path (instant, only if their school issues a recognised domain): set the email → confirm the OTP → call
/complete. - 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
}
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
- 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. - 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).
/completereturns{ approved: false, documentRequired: true }and changes nothing — fall through to the document screen. This is normal, not an error. - A school email on a trusted domain, for the student's own school.
documentRequired: falseonce confirmed./completeapproves immediately —statusflips toAPPROVED, 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:
GET /status→stage: "EMAIL",accountEmailPrefill= the signup email.POST /school-email { email: accountEmailPrefill }→ the proof from signup is reused (otpSent: false).POST /complete→approved: truewhen 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
POST /document/presignwith{ filename, contentType, size }.contentTypemust be one of:image/jpeg,image/png,image/webp,image/heic,application/pdf.size≤ 10MB. Response:{ key, uploadUrl, contentType }—uploadUrlexpires in 5 minutes.PUTthe raw file bytes touploadUrldirectly (not through the API).POST /submitwith{ documentKey: key, claimedSchoolName? }— round-trip thekeyfrom step 1 verbatim; you cannot construct your own. A mismatched or altered key is refused with400 INVALID_DOCUMENT_KEYbefore anything is touched. Submit within 55 minutes of the presign. An older key gets409 DOCUMENT_EXPIRED: run steps 1–3 again. So don't keep thekeyacross an app restart. After a restart, upload again.- Response is the same shape as
/status, now withstage: "REVIEW". A human reviews it — usually same-day, no fixed SLA. Poll/statusor wait for thestudent_verificationpush 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:
- Is this student verified? →
isStudentVerified. Notstatus. During a school changestatuscan bePENDINGorREJECTEDwhile the student is fully verified. - Which screen of the verification flow? →
stage, same table as above. - 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: trueandschoolChange: 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)
- Start. Send the new entry, the same shape as
POST /api/users/me/education.isCurrentis set totrueby the server. The response has the/statusshape. Route onstageexactly like the first verification. - 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. - Wait.
stage: REVIEW. The app stays fully open. - 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
| 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
isCurrentor 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./completereturnsapproved: falsefor it, andstageshowsDOCUMENT. ALREADY_VERIFIEDon/submit. The student is verified and no change is open. A new document only goes in throughPOST /school-change.- Revoked during a school change. The change is dropped.
isStudentVerifiedturnsfalse,purposeisINITIAL, and the revoke rules above apply. - Notifications. A school-change approval or rejection uses its own copy
(see Outcome notification). On tap, refetch
/statusand route onstageas 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.