Skip to main content
Version: Latest
UPDATED 2026-11-03 — schoolName on education entries now allows up to 500 characters (was 120). Raise any client-side limit. Nothing else changes.
{ "currentPassword": "Password123", "currentPassword": "<test-password>", "newPassword": "NewPass123"

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: mobile on every request.

What's new​

  • 2026-10-30 — schoolName on 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 URL PUT /me/avatar returns; users with a broken avatar just upload again. Also: PUT /me/avatar now rejects any avatarUrl other than the publicUrl from step 1 (400 MEDIA_KEY_REJECTED).
  • 2026-10-07 — GET /api/users/:username for 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 grade and school (string | null, the current school) beside isStudentVerified, like every other user object in the API — only for student-verified viewers and on your own profile. The limited: true view (unverified viewer) has neither key. The full education array 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,
}
FieldTypeLimitNotes
firstNamestring | null1–50 charsTrimmed. null or "" clears it.
lastNamestring | null1–50 charsTrimmed. null or "" clears it.
displayNamestring≤ 50 charsThe public name on posts/chat/search.
biostring≤ 160 chars
avatarUrlnull only—Removes the photo. See §3.2.
locationstring≤ 100 charsFree text, not validated against a place list.
websitestring | nullmust be a URL"" is accepted and stored as null.
isPrivateboolean—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​

StatusWhenWhat to show
400A field failed validation — name over 50 chars, website not a URL, avatarUrl sent as a stringField-level error.
401Missing/expired tokenRe-auth.
422The text tripped the content filtercode 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.

StatusWhen
415 UNSUPPORTED_TYPENot an image type we accept.
413 FILE_TOO_LARGEOver 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.

avatarUrl accepts only null here. Sending a URL string is a 400. 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):

FieldTypeLimitNotes
schoolNamestring1–500 chars, requiredThe displayable label — source of truth for rendering.
schoolIdstring≤ 64 charsCatalog id from the school picker. Omit for a school the user typed themselves.
levelenumelementary | middle | high | college | other
gradestring≤ 20 chars"5th", "10th".
startYear / endYearint1900 – next yearNumber, not a string.
isCurrentboolean
city / countrystring≤ 80 chars
orderint≥ 0Display 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 fieldCallBody key
First namePATCH /me/profilefirstName
Last namePATCH /me/profilelastName
Display namePATCH /me/profiledisplayName
BioPATCH /me/profilebio
LocationPATCH /me/profilelocation
WebsitePATCH /me/profilewebsite
Private account togglePATCH /me/profileisPrivate
Change photopresign → PUT bytes → PUT /me/avataravatarUrl (the presigned publicUrl)
Remove photoPATCH /me/profileavatarUrl: null
School / grade / yearPUT /me/educationitems[]
InterestsPUT /api/users/me/interestssee mobile-topics-interests-guide.md
PasswordPUT /me/passwordcurrentPassword, newPassword
Username, email, date of birthnot 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 daysFor how longWhy
Name, username, display name, signup date and the email address180 days from the moment they asked to be deletedSo 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 violation180 days from the moderation decisionEvidence 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​

StatusWhenWhat to show
401 Current password is incorrectcurrentPassword doesn't matchError 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 Errornew password fails the strength ruledetails[].field is newPassword; show your own strength hint rather than the raw ajv message.
429more than 10 attempts per hour on this account"Too many attempts, try again later." The retryAfter field is seconds.
401 Invalid or expired tokenthe access token itself is deadNormal 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.

⤓ Download .md