Skip to main content
Version: Latest
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.
- **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. - **2026-09-08** — Every education response now carries a nested `school` object (`id`, `name`, `state`, `district`) for entries saved with a `schoolId` — read the school's state/district from there, they were never stored on the entry itself. Applies to `GET /api/users/me`, and to `GET` / `POST` / `PUT` / `PATCH` on `/api/users/me/education`. `school` is `null` when the entry has no `schoolId`; nothing else about the shape changed.
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)```
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, everysession 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 thatwindow 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, andneither 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 atday 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.

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-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 school object (id, name, state, district) for entries saved with a schoolId — read the school's state/district from there, they were never stored on the entry itself. Applies to GET /api/users/me, and to GET / POST / PUT / PATCH on /api/users/me/education. school is null when the entry has no schoolId; nothing else about the shape changed.

  • 2026-09-07PATCH /api/users/me/profile now saves firstName and lastName too, so the whole Edit-profile form is one call (except the avatar and school info). Breaking: the same endpoint no longer accepts an avatar URLavatarUrl takes only null, which removes the photo. Setting one has always gone through the presign → PUT /me/avatar flow, and now that is the only way. GET /api/users/me also returns education, 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,
}
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 onlyRemoves 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.
isPrivatebooleanPrivate-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>/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.

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–120 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
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". 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.

⤓ Download .md