UPDATED 2026-09-09 — Deleting an account now gives the user 30 days to change their mind, not 14 — if your screen says 14 days anywhere, fix the copy. Logging in again inside that window still restores everything. Two things are kept after the 30 days and are not restorable: a record of the name, username, display name, signup date and email address, deleted 180 days after the request, and anything already removed for a rule violation, deleted 180 days after that decision. New section: [6 — Deleting the account](#6--deleting-the-account). Correction, same day: an earlier version of this line said the email was kept only as a one-way hash. That was wrong — the address itself is retained, so a legal or support request can be answered. Don't tell users their address is unrecoverable.
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-09-09 — Deleting an account now gives the user 30 days to change their mind, not 14 — if your screen says 14 days anywhere, fix the copy. Logging in again inside that window still restores everything. Two things are kept after the 30 days and are not restorable: a record of the name, username, display name, signup date and email address, deleted 180 days after the request, and anything already removed for a rule violation, deleted 180 days after that decision. New section: 6 — Deleting the account. Correction, same day: an earlier version of this line said the email was kept only as a one-way hash. That was wrong — the address itself is retained, so a legal or support request can be answered. Don't tell users their address is unrecoverable.
-
2026-09-08 — Every education response now carries a nested
schoolobject (id,name,state,district) for entries saved with aschoolId— read the school's state/district from there, they were never stored on the entry itself. Applies toGET /api/users/me, and toGET/POST/PUT/PATCHon/api/users/me/education.schoolisnullwhen the entry has noschoolId; nothing else about the shape changed. -
2026-09-07 —
PATCH /api/users/me/profilenow savesfirstNameandlastNametoo, so the whole Edit-profile form is one call (except the avatar and school info). Breaking: the same endpoint no longer accepts an avatar URL —avatarUrltakes onlynull, which removes the photo. Setting one has always gone through the presign →PUT /me/avatarflow, and now that is the only way.GET /api/users/mealso returnseducation, so the form prefills in a single request.
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
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",
"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",
"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>/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 and the previous avatar, then stores the new URL. The URL you
get back is not the one you sent — always render the returned one.
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–120 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 |
| 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".
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.