UPDATED 2026-11-03 — schoolName on education entries now allows up to 500 characters (was 120). Raise any client-side limit. Nothing else changes.
Mobile Profile — Edit Profile Integration Guide
For mobile development. Everything the Edit profile screen needs: read the current values, save name / bio / display name, change or remove the avatar, and manage school info.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every call below. - Send
X-Client-Type: mobileon every request.
What's new
- 2026-10-30 —
schoolNameon education entries now allows up to 500 characters (was 120). Raise any client-side limit. Nothing else changes. - 2026-10-08 — Deleting an account now hides it right away, not only at day 30. Posts, comments, likes and DMs disappear for everyone else during the 30-day window and all come back if the user logs in again. Update the confirmation copy (suggestion in §6).
- 2026-10-08 — Fixed: changing an avatar a second time left the profile with a broken image. Each upload now gets a new URL (
…/avatars/<userId>/<ts>/display.webp), so image caches refresh on their own. Nothing to change if you already render the URLPUT /me/avatarreturns; users with a broken avatar just upload again. Also:PUT /me/avatarnow rejects anyavatarUrlother than thepublicUrlfrom step 1 (400 MEDIA_KEY_REJECTED). - 2026-10-07 —
GET /api/users/:usernamefor someone you blocked now answers 200 with a limited header:{ id, username, isStudentVerified, displayName, avatarUrl, limited: true, blockedByMe: true }and nothing else. Render it with an Unblock button. Someone who blocked you is still a 404. See Report & block guide §6.1. - 2026-10-07 — Profiles now carry flat
gradeandschool(string | null, the current school) besideisStudentVerified, like every other user object in the API — only for student-verified viewers and on your own profile. Thelimited: trueview (unverified viewer) has neither key. The fulleducationarray rule is unchanged.
Short version — just the calls
GET /api/users/me → prefill the whole form (profile + names + education)
PATCH /api/users/me/profile → save firstName, lastName, displayName, bio,
location, website, isPrivate
(and avatarUrl: null to REMOVE the photo)
POST /api/users/me/avatar/presign → change the photo, step 1
PUT <uploadUrl> → step 2 (raw bytes, straight to storage)
PUT /api/users/me/avatar → step 3 (confirm; builds the webp variants)
GET /api/users/me/education → school info, read
PUT /api/users/me/education → school info, save the whole list at once
POST /api/users/me/education → add one entry
PATCH /api/users/me/education/:id → edit one entry
DELETE /api/users/me/education/:id → remove one entry
PUT /api/users/me/password → change the password (old + new; returns a
NEW token pair — store it)
DELETE /api/users/me → delete the account (password in the body;
30 days to undo by logging back in)
Three things save separately. The text fields are one PATCH, the avatar is
the 3-step upload, school info is its own list. If your Save button changes all
three, fire all three — there is no combined endpoint, deliberately: the avatar
is binary and needs server-side processing, and school info is a list with its
own add/edit/delete affordances.
1 — Prefill the form
GET /api/users/me
Authorization: Bearer <accessToken>
One call gives you everything the form needs:
{
"success": true,
"data": {
"id": "cml805q0u0000iaqhjdfk6z2y",
"email": "alice@example.com",
"username": "alice",
"isStudentVerified": true, // ← show the verified badge when true
"grade": "10th", // ← current school (null if none); full list in `education` below
"school": "Delhi Public School",
"firstName": "Alice", // ← User row, editable below
"lastName": "Kaur", // ← User row, editable below
"dateOfBirth": "2010-05-05T00:00:00.000Z",
"gender": null,
"phoneNumber": null,
"isMinor": true,
"emailVerifiedAt": null,
"onboardedAt": "2026-02-04T12:27:15.068Z", // null ⇒ wizard unfinished
"onboardingCompleted": true, // `onboardedAt !== null`, precomputed
"profile": {
"displayName": "Alice Johnson",
"bio": "hello",
"avatarUrl": "https://…/avatars/<userId>/display.webp",
"location": "Mumbai, IN",
"website": null,
"isPrivate": false,
},
"education": [
// ← school info, same rows as GET /me/education
{
"id": "edu_…",
"schoolId": "sch_…",
"schoolName": "Delhi Public School",
"level": "high",
"grade": "10th",
"startYear": null,
"endYear": null,
"isCurrent": true,
"city": "Pune",
"country": "IN",
"order": 0,
"school": {
// ← joined catalog row, null when schoolId is null
"id": "sch_…",
"name": "Delhi Public School",
"state": "Maharashtra",
"district": "PUNE",
},
},
],
"_count": { "followers": 12, "following": 30, "posts": 4 },
},
}
education is ordered by order, then by creation time — render it in that
order and it matches GET /api/users/me/education exactly.
Name vs display name. firstName/lastName are the real name on the user
record. profile.displayName is what appears on posts, in chat and in search.
They are independent — editing one does not touch the other. If your form shows
a single "Full name" field, map it to firstName + lastName yourself (split on
the first space, the way signup does).
2 — Save the text fields
PATCH /api/users/me/profile
Authorization: Bearer <accessToken>
Content-Type: application/json
Partial update — send only what changed. Any field you omit is left alone.
{
"firstName": "Alice",
"lastName": "Kaur",
"displayName": "Alice K",
"bio": "10th grade · debate club",
"location": "Pune, IN",
"website": "https://alice.example.com",
"isPrivate": false,
}
| Field | Type | Limit | Notes |
|---|---|---|---|
firstName | string | null | 1–50 chars | Trimmed. null or "" clears it. |
lastName | string | null | 1–50 chars | Trimmed. null or "" clears it. |
displayName | string | ≤ 50 chars | The public name on posts/chat/search. |
bio | string | ≤ 160 chars | |
avatarUrl | null only | — | Removes the photo. See §3.2. |
location | string | ≤ 100 chars | Free text, not validated against a place list. |
website | string | null | must be a URL | "" is accepted and stored as null. |
isPrivate | boolean | — | Private-profile toggle — see mobile-follow-graph-guide.md. |
Response — the profile plus the two name fields merged in, so you can write the whole form state back from one response:
{
"success": true,
"data": {
"id": "…",
"userId": "…",
"firstName": "Alice",
"lastName": "Kaur",
"displayName": "Alice K",
"bio": "10th grade · debate club",
"avatarUrl": "https://…/display.webp",
"location": "Pune, IN",
"website": null,
"isPrivate": false,
"createdAt": "…",
"updatedAt": "…",
},
}
Name and profile fields are written in one transaction — either the whole save lands or none of it does. You never need to reconcile a half-saved form.
2.1 — Errors
| Status | When | What to show |
|---|---|---|
400 | A field failed validation — name over 50 chars, website not a URL, avatarUrl sent as a string | Field-level error. |
401 | Missing/expired token | Re-auth. |
422 | The text tripped the content filter | code is CONTENT_BLOCKED, message is "Please use kinder words.", and field names the offending input (e.g. bio). Highlight that field. See mobile-content-moderation-guide.md. |
The filter applies to every text field here — bio, displayName, firstName,
lastName, location. It does not apply to website (validated as a URL).
3 — Avatar
3.1 — Change the photo (3 steps)
The avatar is not part of the PATCH body — it is binary, and the server builds
two square webp variants from it (480×480 display, 240×240 thumbnail).
Step 1 — ask for an upload URL:
POST /api/users/me/avatar/presign
Content-Type: application/json
{ "contentType": "image/jpeg", "size": 842311 }
{
"success": true,
"data": {
"uploadUrl": "https://…?X-Amz-Signature=…", // PUT the bytes here
"publicUrl": "https://…/avatars/<userId>/1757246000.jpg",
"key": "avatars/<userId>/1757246000.jpg",
},
}
JPG, PNG and WebP only. size must be the real byte length — it is pinned into
the signature, so a mismatched upload is rejected by storage.
| Status | When |
|---|---|
415 UNSUPPORTED_TYPE | Not an image type we accept. |
413 FILE_TOO_LARGE | Over the image size cap. |
Step 2 — upload the bytes straight to uploadUrl with PUT and the same
Content-Type. Does not go through our API — no Authorization header.
Step 3 — confirm:
PUT /api/users/me/avatar
Content-Type: application/json
{ "avatarUrl": "<publicUrl from step 1>" }
{
"success": true,
"data": { "avatarUrl": "https://…/avatars/<userId>/<ts>/display.webp" },
}
This step is what actually processes the image: it verifies the bytes really are
an image (415 if not), writes display.webp + thumbnail.webp, deletes the
raw upload, stores the new URL, then deletes the previous avatar. The URL you
get back is not the one you sent — always render the returned one. Every
upload gets a new URL, so you never need to cache-bust it yourself.
Send exactly the publicUrl from step 1 — any other URL (another
host, another user's key) is 400 MEDIA_KEY_REJECTED.
There is a multipart fallback, PATCH /api/users/me/avatar (one file field,
JPG/PNG), for clients that cannot do a presigned PUT. Prefer the 3-step flow —
the fallback skips variant generation.
3.2 — Remove the photo
PATCH /api/users/me/profile
Content-Type: application/json
{ "avatarUrl": null }
Clears the avatar and deletes the stored files. Response has
"avatarUrl": null.
avatarUrlaccepts onlynullhere. Sending a URL string is a400. Setting an avatar has to go through §3.1 so the variants get built and the old file is cleaned up — a client-supplied URL skipped both. If you were previously PATCHing an avatar URL, switch to the presign flow.
4 — School info
School info is a list (Education rows), not a single field — a user can
have several entries. It lives on its own endpoints.
GET /api/users/me/education
POST /api/users/me/education
PUT /api/users/me/education
PATCH /api/users/me/education/:id
DELETE /api/users/me/education/:id
Entry shape (all fields optional except schoolName):
| Field | Type | Limit | Notes |
|---|---|---|---|
schoolName | string | 1–500 chars, required | The displayable label — source of truth for rendering. |
schoolId | string | ≤ 64 chars | Catalog id from the school picker. Omit for a school the user typed themselves. |
level | enum | elementary | middle | high | college | other | |
grade | string | ≤ 20 chars | "5th", "10th". |
startYear / endYear | int | 1900 – next year | Number, not a string. |
isCurrent | boolean | ||
city / country | string | ≤ 80 chars | |
order | int | ≥ 0 | Display order. Defaults to list position on bulk save. |
school is response-only. Every education response (including GET /api/users/me)
joins the catalog row behind schoolId and returns it as a nested object:
"school": { "id": "sch_…", "name": "Delhi Public School", "state": "Maharashtra", "district": "PUNE" }
It is null when the entry has no schoolId. state and district exist only
here — the entry's own city/country are free text the user typed. Don't send
school in a request body; it is ignored.
Always send schoolId when the user picked from the catalog. Get it from the
school picker in mobile-schools-guide.md — it is
what links the user to a real school. schoolName alone still saves and still
renders, it just isn't linked.
For an Edit-profile screen, use PUT (bulk replace). It takes the whole list
and replaces it in one transaction, which is exactly what a form's Save button
means:
PUT /api/users/me/education
Content-Type: application/json
{
"items": [
{ "schoolId": "sch_…", "schoolName": "Delhi Public School",
"level": "high", "grade": "10th", "isCurrent": true,
"city": "Pune", "country": "IN", "order": 0 }
]
}
Sending { "items": [] } removes every entry. The response is the saved list,
re-read in display order — render from it, don't re-render from your local state.
POST / PATCH / DELETE exist for a screen that edits one entry at a time.
PATCH and DELETE return 404 for an id that isn't yours — the same 404 a
missing row gives, so ids can't be probed.
5 — What the form maps to
| Form field | Call | Body key |
|---|---|---|
| First name | PATCH /me/profile | firstName |
| Last name | PATCH /me/profile | lastName |
| Display name | PATCH /me/profile | displayName |
| Bio | PATCH /me/profile | bio |
| Location | PATCH /me/profile | location |
| Website | PATCH /me/profile | website |
| Private account toggle | PATCH /me/profile | isPrivate |
| Change photo | presign → PUT bytes → PUT /me/avatar | avatarUrl (the presigned publicUrl) |
| Remove photo | PATCH /me/profile | avatarUrl: null |
| School / grade / year | PUT /me/education | items[] |
| Interests | PUT /api/users/me/interests | see mobile-topics-interests-guide.md |
| Password | PUT /me/password | currentPassword, newPassword |
| Username, email, date of birth | not editable | — |
Username, email and date of birth are read-only on this screen — there is no endpoint to change them.
6 — Deleting the account
DELETE /api/users/me body: { "password": "<their current password>" } → 202
Password-gated, and it takes effect immediately: the account goes dormant, every
session and refresh token is revoked, live sockets are dropped. Treat the 202
as a logout — your tokens are dead from that moment.
It is reversible for 30 days. Logging in with the same credentials inside that
window restores the account and everything in it; POST /api/auth/login returns
data.reactivated: true so you can say "welcome back" instead of "logged in".
During those 30 days the account is invisible to everyone else (since 2026-10-08), but nothing is deleted yet:
- the profile returns
404, and the account is left out of people search, discover and @mentions; - their posts leave the feed, lists, search and profile tabs, and post detail returns
404; - their comments and likes are left out of threads and likers lists;
- reposts of their posts show "Original removed";
- DMs with them carry
peerUnavailable: { reason: "deactivated" }and refuse new messages.
All of it comes back on reactivation. Suggested confirmation copy: "Your profile and posts will be hidden right away and permanently deleted after 30 days."
After 30 days — or once the purge has started — the same login returns 410 Gone
and there is nothing to restore.
Say 30 days in your confirmation screen. Two things deliberately outlive it, and neither is restorable:
| Kept after the 30 days | For how long | Why |
|---|---|---|
| Name, username, display name, signup date and the email address | 180 days from the moment they asked to be deleted | So a legal or support request about the account can be answered. Nothing in the app serves this — there is no endpoint that reads it. |
| Anything already removed for a rule violation | 180 days from the moderation decision | Evidence for a dispute or appeal. It stays hidden the whole time — nobody, including the author, can see it. |
Both windows run from those two events, not from each other: the profile goes at day 30 and the identity record at day 180 from the same request, not 30 + 180.
There is no "download my data" endpoint yet — don't offer one in the UI.
7 — Changing the password
For a user who knows their current password. Forgot it? That is the separate
code-by-email flow in the auth guide
(POST /api/auth/forgot-password), not this endpoint.
PUT /api/users/me/password
Authorization: Bearer <accessToken>
Content-Type: application/json
X-Client-Type: mobile
{
"currentPassword": "<test-password>",
"newPassword": "NewPass123"
}
{
"success": true,
"message": "Password changed. All other devices have been signed out.",
"data": {
"accessToken": "eyJhbGciOi…",
"refreshToken": "eyJhbGciOi…"
}
}
Store the returned pair immediately — this is the part that will bite you.
A successful change revokes every refresh token and session on the account,
including the one this device is holding, and drops live sockets. The response
mints a replacement session for the device that made the call, so the user stays
signed in here — but only if you overwrite your stored accessToken and
refreshToken with the ones in data. Keep the old pair and the next
POST /api/auth/refresh returns 401 and you bounce a user to the login screen
seconds after they changed their password.
Web clients get the new refresh token as the usual httpOnly cookie, so data
carries only accessToken there. Mobile (X-Client-Type: mobile) gets both.
Their other devices are logged out — those clients see 401 on their next
refresh and should go to the login screen. An access token already in flight on
another device keeps working until it expires (15 min), so "signed out
everywhere" is not instant to the second.
New password rules — same as signup: at least 8 characters, one uppercase, one lowercase, one digit. Validate client-side so the user isn't round-tripping.
7.1 — Errors
| Status | When | What to show |
|---|---|---|
401 Current password is incorrect | currentPassword doesn't match | Error under the current password field. Don't clear the new-password fields. |
400 code: "PASSWORD_UNCHANGED" | new password identical to the current one | "Choose a password you haven't used here." |
400 Validation Error | new password fails the strength rule | details[].field is newPassword; show your own strength hint rather than the raw ajv message. |
429 | more than 10 attempts per hour on this account | "Too many attempts, try again later." The retryAfter field is seconds. |
401 Invalid or expired token | the access token itself is dead | Normal refresh/login handling. |
There is no email or push notification on a password change yet — if your design promises the user one, say so and it can be added.