UPDATED 2026-11-03 — Signup no longer verifies the student, even with a trusted school email. POST /api/onboarding/signup now always returns isStudentVerified: false. Verification happens after the education screen (Screen 3b): for a trusted school email it is one tap with no second code — see the [student verification guide](../student-verification/mobile-student-verification-guide.md#accountemailprefill--reuse-dont-re-ask). Run Screen 3 before 3b: the email path only approves for the school entered there. After onboarding, a verified student changes school through the new school-change flow.
Mobile Onboarding — Integration Guide
For the mobile developer. Read top-to-bottom. Every section is a step you'll implement. No backend changes needed on your side.
This document explains how to drive registration from the mobile app. Everything you need is described here.
- API base URL (local):
http://localhost:3001 - All requests are JSON. Send
Content-Type: application/json. - Send the header
X-Client-Type: mobileon every request so the API treats you as a mobile client (skips refresh-token cookies, returns tokens in JSON bodies).
What's new
- 2026-10-30 — Signup no longer verifies the student, even with a trusted school email.
POST /api/onboarding/signupnow always returnsisStudentVerified: false. Verification happens after the education screen (Screen 3b): for a trusted school email it is one tap with no second code — see the student verification guide. Run Screen 3 before 3b: the email path only approves for the school entered there. After onboarding, a verified student changes school through the new school-change flow. - 2026-10-28 — Signup now needs a verified email, and phone signup is off. On Screen 2, call
POST /api/verification/sendfirst. It returns409 ACCOUNT_EXISTSbefore sending a code when the email already has an account, so send the user to login right there. Then send the 6-digit code ascodein/signup. No code →400 OTP_REQUIRED.phoneNumber→400 PHONE_SIGNUP_DISABLED. - 2026-10-07 — Every user object here now also carries
gradeandschool(bothstring | null, from the user's current school) next toisStudentVerified— show them under the name.null= no current school on file. Only sent to student-verified viewers: an unverified viewer gets neither key on anyone but themselves, so treat missing as "hidden". Additive for verified users. Details: Student verification guide. - 2026-10-06 — Interest names and icons are now display-ready, and the guide was wrong about unknown slugs.
GET /api/users/topics?representative=1returns real names ("Travel & Exploration", "AI & Machine Learning" — no more lowercase with the&missing) and aniconon every one of the 86,icon_<slug with hyphens removed>(e.g.icon_travelexploration) with six exceptions below. Send theslugfield from that response verbatim — don't build slugs from names. Unknown slugs are silently dropped, not rejected, so a pick with a bad slug fails as400 At least 3 valid topics required. Six keep their existing asset names, so the rule has exceptions:philosophy→icon_Philosophy,psychology→icon_Psychology,environmental-science→icon_EnvironmentalScience,product-ui-ux-design→uxdesign,photography→icon_camera,team-sports→icon_group— no asset changes needed. - 2026-10-05 — Signup now rejects anyone over 25 (was 100). Same 422
INVALID_AGEas the under-13 case — show it on the date-of-birth field, and cap your date picker so users can't pick a birth date older than 25 years. Existing accounts are untouched.
Short version — just the API calls
Onboarding owns two endpoints that matter — signup and /users/me/onboarding/complete
(plus the soft username-available check). Everything else is the app's regular
authenticated API. Don't confuse /api/users/me/onboarding/complete (live) with
/api/onboarding/complete (deprecated — never call it).
0. POST /api/verification/send → 6-digit code to the email (409 if account exists)
1. POST /api/onboarding/signup → { user, accessToken, refreshToken }
[optional] student verification screen — see its own guide, uses accessToken
2. POST /api/users/me/education → the education screen
3. The rest of the wizard (interests, intent, avatar, bio, activities,
achievements) — regular accessToken calls. §5 lists them.
4. POST /api/users/me/onboarding/complete → "the wizard is over" — on finish OR on Skip
Auth headers:
- 0, 1 — no auth.
- 2, 3, 4 —
Authorization: Bearer <accessToken>(returned by 1). - Send
X-Client-Type: mobileon every API call.
Minimum bodies:
// 0. Send the code.
{ "channel": "email", "identifier": "kid@example.com", "purpose": "email_verification" }
// 1. Create the account. `email`, `password`, `code` and
// data.basic.dateOfBirth are required.
{
"email": "kid@example.com",
"password": "MyStr0ngPass",
"code": "123456", // from step 0
"data": {
"basic": { "dateOfBirth": "2012-04-23", "gender": "FEMALE" },
"name": { "fullName": "Ada Lovelace" }
}
}
// 2. Education — one call per school, or PUT with { items: [...] } for all.
{ "schoolName": "Lincoln High", "level": "high", "grade": "10th", "isCurrent": true }
// 4. Finished. No body.
{}
Rules of thumb:
- Hold Screen 1 locally; it is sent with Screen 2's call. Nothing hits the server before the user has entered a password.
/signupis not retry-safe the way/completewas. A second call with the same email is409 ACCOUNT_EXISTS, not a replay. Store the tokens from the 201. If you genuinely lost them, log in with the password.- Every screen after signup commits on its own, immediately. There is nothing to "finish" except the flag in step 4.
- Skip is a finish. Several screens are skippable and the user can leave the wizard for the app at any point. Whenever they do — Skip, "Do this later", or the last screen — call step 4 at that moment. Without it the server still thinks they dropped off.
- 401 on any authenticated call ⇒ refresh (or log in again). The account already exists — never restart the wizard from Screen 1 because of a 401.
codeis required. Email only — phone signup is off.
Full walkthrough, schemas, error tables, and curl examples below.
TL;DR — wireframe screens mapped to API calls
┌────────────────────────────────────────────────────────────────────────────┐
│ ACCOUNT CREATION │
│ Screen 1 DOB + Gender │
│ → LOCAL ONLY. Hold values in app state. │
│ Screen 2 Full name + Email + Password │
│ → POST /api/onboarding/signup │
│ Body bundles Screen 1 + Screen 2 inside `data`. │
│ Returns the real account + accessToken + refreshToken. │
│ The user is LOGGED IN from here. │
├────────────────────────────────────────────────────────────────────────────┤
│ ONBOARDING (everything below runs on the accessToken) │
│ Screen 3 School / education ← the first onboarding screen │
│ → POST /api/users/me/education (or PUT for the whole list) │
│ Screen 3b Student verification (OPTIONAL) │
│ → see the separate Student Verification guide. Skippable — can │
│ also be done later from profile settings. │
│ Screen 4 Interests (topic slugs) │
│ → POST /api/users/me/interests │
│ Screen 5 Intent (why join) │
│ → PUT /api/intents/me │
│ Screen 6 Avatar + Bio │
│ → POST /api/users/me/avatar/presign → PUT <uploadUrl> → PUT │
│ /api/users/me/avatar { avatarUrl } │
│ → PATCH /api/users/me/profile { bio } │
│ Screen 7 Activities (each with photos) │
│ For each media file: POST /api/media/presign → PUT <uploadUrl> │
│ → POST /api/users/me/activities { ..., media: [{ url }] } │
│ Screen 8 Achievements (each with photos) │
│ Same pattern → POST /api/users/me/achievements │
├────────────────────────────────────────────────────────────────────────────┤
│ Final Review screen — OR the user taps Skip on any skippable screen. │
│ Nothing to commit, each screen already saved. │
│ → POST /api/users/me/onboarding/complete │
│ Flips `onboardingCompleted` to true. Call it on finish AND on │
│ skip, or the wizard is shown again on their next login. │
└────────────────────────────────────────────────────────────────────────────┘
Full request/response shapes for Screens 3–8 live in Swagger UI
(/api/docs) under their own modules (Users, Intents, Activities,
Achievements, Media) — this guide documents signup and the completion flag
in depth, since those are the onboarding-specific parts.
Rule of thumb: Screens 1–2 are a fixed prefix — nothing else can happen until the account exists. Screens 3 onward are independent authenticated calls with no dependency on each other, so the order is yours — which is also why resuming means "first screen whose data is still empty", not a fixed screen (see §4).
The email must be verified with a code before the account is created. Screen 2 calls
POST /api/verification/send, then/onboarding/signupwith thecode. See "Send the code" below.
1. Get TypeScript types from our OpenAPI spec
The API publishes its OpenAPI 3 spec at:
http://localhost:3001/api/docs/json
Use openapi-typescript (or orval / kubb) to generate types on the
mobile side. Example with openapi-typescript:
npx openapi-typescript http://localhost:3001/api/docs/json -o src/api/schema.ts
Re-run when the backend ships a new API version (I'll tell you). You can
also browse http://localhost:3001/api/docs for the interactive Swagger UI.
Deprecated endpoints are marked as such in the spec — don't build against them.
If you need runtime validation on the mobile side (form validation that
mirrors the server's Zod rules), we can publish @ksn/validators
to a private registry. For now, types are enough.
2. Authentication — one token set, from Screen 2 on
There is only one token pair now, and you get it the moment the account is created:
| Token | When you get it | What it's for |
|---|---|---|
| accessToken | POST /api/onboarding/signup response | Every authenticated call — the rest of onboarding and all normal app usage |
| refreshToken | the same response (mobile only) | Rotating the access token, exactly as after a login |
Store both in secure storage (Keychain on iOS / Keystore on Android). The
onboardingToken from the old session flow is gone — if your code still holds
one, delete that path.
No follow-up login call.
/signupmints a device session itself, so thePOST /api/auth/loginstep the old flow needed afterwards is no longer part of onboarding. Login is only for returning users.
2b. Interest + Intent catalogs
Don't hardcode the lists on the client — fetch them from the API. Both
endpoints accept your accessToken.
Per-route rate-limit: 30 req/min per token. Responses are
Cache-Control: public, max-age=3600 — cache for the session, refetch on
cold start.
Interests — GET /api/users/topics?representative=1
Returns the curated interest set (~86 topics across the 9 categories). Group on
the client by the category field to render section headers (the wireframe shows
two-level lists like "Technology & Innovation → Robotics / AI & ML / …"). Persist
slug values in your local state; that's what POST /api/users/me/interests
expects (see below).
?representative=1is required here. Without it the route returns the 9 categories — the list the create screens pick from, not this one. The server records the category behind each pick for you, so this step stays fine-grained.
GET /api/users/topics?representative=1
Authorization: Bearer <accessToken>
X-Client-Type: mobile
Response (truncated):
{
"success": true,
"data": [
{
"id": "...",
"slug": "mathematics",
"name": "Mathematics",
"category": "Academics & Learning",
"description": null,
"icon": "icon_mathematics",
"order": 1
},
{
"id": "...",
"slug": "ai-machine-learning",
"name": "AI & Machine Learning",
"category": "Technology & Innovation",
"icon": "icon_aimachinelearning",
"order": 11
}
// ... ~86 entries
]
}
Categories currently shipping (in order):
- Academics & Learning
- Technology & Innovation
- Business & Leadership
- Arts & Creativity
- Sports & Fitness
- Competitive Activities
- Gaming & Digital Culture
- Lifestyle
- Entertainment & Pop Culture
Intents — GET /api/intents
GET /api/intents
Authorization: Bearer <accessToken>
X-Client-Type: mobile
Response:
{
"success": true,
"data": [
{
"slug": "competitions",
"name": "Competitions",
"description": "Looking for competitive events & challenges",
"icon": "trophy",
"order": 1
},
{
"slug": "events",
"name": "Events",
"description": "Want to attend workshops & social gatherings",
"icon": "calendar",
"order": 2
},
{
"slug": "find-teammates",
"name": "Find Teammates",
"description": "Searching for people to collaborate with",
"icon": "users",
"order": 3
},
{
"slug": "communities",
"name": "Communities",
"description": "Interested in joining interest-based groups",
"icon": "users-round",
"order": 4
},
{
"slug": "volunteering",
"name": "Volunteering",
"description": "Want to make a positive impact",
"icon": "heart-handshake",
"order": 5
},
{
"slug": "exploring",
"name": "Exploring",
"description": "Just checking out opportunities",
"icon": "compass",
"order": 6
}
]
}
Sending the user's choices
PUT /api/intents/me
Authorization: Bearer <accessToken>
{ "intentSlugs": ["competitions","find-teammates"] }
POST /api/users/me/interests
Authorization: Bearer <accessToken>
{ "topicSlugs": ["ai-machine-learning","robotics","music"] }
(POST /me/interests sets them the first time — min 3; PUT /me/interests
replaces the full set later, e.g. from profile settings. Either works here;
use POST for the onboarding screen.) Unknown slugs are silently
dropped, and the call fails with 400 At least 3 valid topics required
if fewer than 3 are left — so always send the slug value from the fetched
catalog verbatim (never one built from the display name). Full shapes in Swagger UI under Users
and Intents.
3. The flow, step by step
Screen 1 — DOB + Gender (local only)
Collect date of birth and gender. No network call here. Hold the values
in local state — they'll be sent as data.basic on the next request.
Why: the account is created at Screen 2 (it needs the password). Saves a round-trip and keeps Screen 1 abort-friendly.
Screen 2 — Name + Email + Password → create the account
This is the first server call, and the only one onboarding owns. Bundle Screen 1 + Screen 2 values into the body.
codeis required. Get it first withPOST /api/verification/send— see "Send the code" below.
Validate password locally first (server enforces the same rules):
- min 8 chars
- at least one uppercase
- at least one lowercase
- at least one digit
Then hit:
POST /api/onboarding/signup
Content-Type: application/json
X-Client-Type: mobile
{
"email": "kid@example.com",
"password": "MyStr0ngPass",
"code": "123456", // from /verification/send
"parentEmail": "parent@example.com", // optional — only if minor
"data": {
"basic": {
"dateOfBirth": "2012-04-23", // from Screen 1 — REQUIRED
"gender": "FEMALE" // from Screen 1
},
"name": {
"fullName": "Ada Lovelace" // from Screen 2 (single Full Name input)
// — or send firstName/lastName separately if you collect two fields.
// "username": "ada_l" // optional; auto-derived if absent
}
}
}
Response (201):
{
"success": true,
"data": {
"user": {
"id": "...",
"email": "kid@example.com",
"username": "ada_l",
"isMinor": true,
"requiresParentalConsent": false,
"onboardedAt": null,
"onboardingCompleted": false,
"createdAt": "2026-09-30T..."
},
"accessToken": "eyJhbGciOi...",
"refreshToken": "eyJhbGciOi..."
}
}
Store both tokens. onboardingCompleted: false is expected here — the account
exists, the wizard doesn't yet.
What's required: email, password, code, and
data.basic.dateOfBirth (age must land between 13 and 25).
Everything else is optional, including username. When absent the server
derives one from firstName (or the email local-part, or "user"), sanitized
to [a-z0-9_] and probed against the DB so it doesn't collide. The user can
edit it later from the profile screen.
Name handling — send the wireframe's single input as data.name.fullName
and the server splits it: first whitespace-delimited token → firstName,
remainder → lastName. If you collect two fields, send them explicitly and
they win over the split.
displayName falls back to fullName, then firstName, then the username.
Editable later via PATCH /api/users/me/profile.
Not accepted here: profile.bio and profile.avatarUrl. Both are Screen 6
and go through PATCH /api/users/me/profile and the avatar endpoints, which run
the image checks. Sending them is silently ignored.
Errors you'll see
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 422 | MISSING_DOB | data.basic.dateOfBirth absent | Send Screen 1's value; block Continue until it's set. |
| 422 | INVALID_DOB | unparseable date | Fix the format (YYYY-MM-DD). |
| 422 | INVALID_AGE | age outside 13–25 | Show on the date-of-birth field. |
| 409 | ACCOUNT_EXISTS | email already belongs to an account | Send the user to the login screen. Usually caught earlier, at /verification/send. |
| 400 | OTP_REQUIRED | no code, and no recent /verification/confirm | Send the code first (see below). |
| 400 | PHONE_SIGNUP_DISABLED | phoneNumber sent | Sign up with email only. |
| 409 | UNIQUE_VIOLATION | the chosen username was taken in the race | Prompt for a different username and retry. |
| 400 | INVALID_OTP | wrong or expired code | Let the user retry, or send a new code. |
| 429 | RATE_LIMIT_EXCEEDED | too many attempts | Show "try later". |
Send the code
Two steps come before the POST /api/onboarding/signup call:
-
Send the code (right after the user enters their email on Screen 2)
POST /api/verification/sendContent-Type: application/jsonX-Client-Type: mobile{"channel": "email","identifier": "kid@example.com","purpose": "email_verification"}Response:
{ "success": true, "data": { "expiresIn": 300 } }409 ACCOUNT_EXISTS→ this email already has an account. No code is sent. Send the user to the login screen.6-digit code, valid 5 min, 3-attempt cap, rate-limited at 5 sends per 15 min.
-
Collect OTP locally and include it as the
codefield in thePOST /api/onboarding/signupbody. The route confirms and consumes the code inline. Without it the signup is400 OTP_REQUIRED.
Checking username availability live
Before the user submits a username, do a soft check (public, no auth):
GET /api/onboarding/username-available?u=ada_l
{ "success": true, "data": { "username": "ada_l", "available": true } }
This is a soft check — there's a race window, and /signup does the hard
one (409 UNIQUE_VIOLATION).
Screen 3 — School / education (the first onboarding screen)
The account already exists, so this is a normal authenticated write:
POST /api/users/me/education
Content-Type: application/json
Authorization: Bearer <accessToken>
X-Client-Type: mobile
{
"schoolName": "Lincoln High",
"schoolId": "...", // optional — set it when the user picked from /api/schools
"level": "high", // elementary | middle | high | college | other
"grade": "10th",
"isCurrent": true,
"city": "Pune",
"country": "IN"
}
Returns 201 with the stored row (including the joined catalog school, when
schoolId was set). Other verbs on the same resource:
| Method | Path | Use |
|---|---|---|
GET | /api/users/me/education | List — prefill the screen when the user comes back |
PUT | /api/users/me/education | Replace the whole list: { "items": [ … ] } |
PATCH | /api/users/me/education/:id | Edit one entry |
DELETE | /api/users/me/education/:id | Remove one entry |
A verified student can't change their current school with these routes
(409 SCHOOL_CHANGE_NEEDS_VERIFICATION), and no edit works while a school
change is open (409 SCHOOL_CHANGE_IN_PROGRESS). Nobody is verified yet on
this screen during onboarding, so here they always work. See the
student verification guide.
PUT is the easy one for a screen that lets the user add several schools before
tapping Continue: send the full array, the server replaces what's there.
School search / submission (the picker that produces schoolId) is its own
guide — see the Schools guide.
Screens 4–8 — the rest of the wizard
Not documented in depth here — these are the app's regular, already-shipped
authenticated endpoints, exactly what a logged-in user hits from profile
settings. Full request/response shapes: Swagger UI (/api/docs) under
Users, Intents, Activities, Achievements, Media.
| Screen | Endpoint(s) |
|---|---|
| Screen 4 — Interests | POST /api/users/me/interests (min 3 topicSlugs) |
| Screen 5 — Intent | PUT /api/intents/me ({ intentSlugs }) |
| Screen 6 — Avatar | POST /api/users/me/avatar/presign → PUT <uploadUrl> → PUT /api/users/me/avatar { avatarUrl } |
| Screen 6 — Bio | PATCH /api/users/me/profile { bio } |
| Screen 7 — Activities | Media (if any): POST /api/media/presign → PUT <uploadUrl> → publicUrl. Then POST /api/users/me/activities with that url in media: [{ type: "image", url }] |
| Screen 8 — Achievements | Same media pattern, then POST /api/users/me/achievements |
Each of these is its own independent, atomic call — an activity commits with its media in one request, same for an achievement. Each screen's data is live the moment its call succeeds.
Final — tell us the wizard is done
POST /api/users/me/onboarding/complete
Authorization: Bearer <accessToken>
X-Client-Type: mobile
{ "success": true, "data": { "onboardedAt": "2026-09-30T...", "onboardingCompleted": true } }
Idempotent — a replay returns the same timestamp. Don't skip this call: it is the only thing that tells us the user is done with the wizard, and without it they see the wizard again on their next login.
Call it on Skip too. Some screens are skippable and the user can go straight to the app after a few of them. "Done" means the user is leaving the wizard, not every screen was filled: the server does not derive it from what's saved (education, interests, …), so skipping without this call looks identical to the app being killed mid-wizard.
4. Common scenarios
The user skips the rest of the wizard
They tap Skip / "Do this later" after any screen and land in the app. Call
POST /api/users/me/onboarding/complete before navigating. Whatever they filled
in stays saved; the rest can be added from profile settings. No call ⇒ they are
treated as a drop-off and the wizard returns at next login.
The user quits mid-wizard and comes back later
This is the hard-kill case — the app closed without a Skip or finish, so
/complete never ran. This is the one to get right. The account exists from Screen 2 on, so the user
is a normal logged-in account with an unfinished profile.
On the next launch:
- If you still hold a valid
accessToken/refreshToken, callGET /api/users/me— it returnsonboardingCompleted. - Otherwise the user logs in:
POST /api/auth/loginreturnsdata.user.onboardingCompletedin the same response.
// POST /api/auth/login — a user who dropped out after Screen 2
{
"success": true,
"data": {
"user": { "id": "...", "email": "kid@example.com", "username": "ada_l",
"onboardingCompleted": false },
"accessToken": "…",
"refreshToken": "…"
}
}
false ⇒ show the onboarding flow instead of the app. Login returns only that
flag — not what was saved — so work out where to resume yourself:
GET /api/users/me— returnseducation[]andprofile. Non-emptyeducation⇒ the education screen was completed; skip it.GET /api/users/me/interests,GET /api/intents/me— empty ⇒ that screen is still to do. (GET /api/users/me/activitiesandGET /api/users/me/achievementsif you resume that far.)
Resume at the first screen whose data is empty and prefill the rest. The server
does not return a resume pointer; a local step pointer is a fine shortcut,
but the GETs are the truth (it's gone after a reinstall or on a new device).
If everything is empty, start at education.
Every screen's write is idempotent enough to re-run (education PUT replaces,
interests PUT replaces, intent PUT replaces), so re-walking the wizard never
duplicates anything except activities/achievements, which are POST-per-item.
The user backgrounds the app mid-onboarding
Nothing to do server-side. Local state persists; resume from the same screen.
The user switches devices mid-onboarding
A non-issue now: they have a real account, so they log in on the new device and
onboardingCompleted: false puts them back in the wizard with everything they'd
already saved.
The user abandons onboarding entirely
If they tapped Skip, you already called /complete (see above) and there is
nothing more to do. If they just killed the app, onboardingCompleted stays
false until something calls the completion endpoint. Either way nothing needs
cleaning up: they have an account with a thin profile, like any user who never
filled everything in. (Tell us if you'd rather the app stop nagging after N attempts — that's
a product call, not an API limit.)
The user wants to change their email mid-flow
The account already exists, so this is an account-settings change, not an onboarding one. Ping the backend team — self-service email change isn't built yet.
A 401 on an authenticated call
Refresh the access token (POST /api/auth/refresh) or have the user log in
again. Never restart the wizard from Screen 1 — the account exists; a
re-signup attempt is a 409.
5. Reference — the two onboarding-owned endpoints
| Method | Path | Auth | Body |
|---|---|---|---|
POST | /api/onboarding/signup | none | { email, password, code, parentEmail?, data? } — creates the account, returns accessToken + refreshToken |
POST | /api/users/me/onboarding/complete | accessToken | — flips onboardingCompleted to true. Call on finish and on Skip. (Not the deprecated /api/onboarding/complete.) |
GET | /api/onboarding/username-available?u=... | none | — soft check |
POST | /api/verification/send | none | { channel, identifier, purpose } — sends the signup code; 409 if the email has an account |
Everything else the wizard touches is the regular app API (/api/users/me/*,
/api/intents/*, /api/media/*, /api/schools/*) — see Swagger UI.
Deprecated — do not build against these. They still answer so that in-flight integrations don't break, and they will be deleted:
| Method | Path | Replaced by |
|---|---|---|
POST | /api/onboarding/session | POST /api/onboarding/signup |
GET | /api/onboarding/session | GET /api/users/me |
PATCH | /api/onboarding/session | the individual screens' own endpoints |
POST | /api/onboarding/complete | POST /api/onboarding/signup |
DELETE | /api/onboarding/session | nothing — there is no staged session to abandon |
POST | /api/onboarding/media-presign | POST /api/users/me/avatar/presign, POST /api/media/presign |
DELETE | /api/onboarding/media | same as above |
All responses follow the standard envelope:
{ "success": true, "data": { ... } }
Errors:
{ "success": false, "error": "Human message", "code": "MACHINE_CODE" }
6. Questions / changes
If you need:
- A server-side resume pointer (which screen to return to)
- A different OTP channel (WhatsApp, push)
- Server-side runtime validators (shared
@ksn/validatorspackage on the mobile side) - New fields on
Education
… ping the backend team. Screens 4–8 questions (interests/intent/activities/achievements/avatar/profile) belong to their own modules — ask about those separately.
7. Quick test with curl
# 0. Send the code. Read it from the email, or from the API log in dev when
# the console email channel is on (SMTP env blank).
curl -X POST http://localhost:3001/api/verification/send \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"channel":"email","identifier":"test@example.com","purpose":"email_verification"}'
# 1. Create the account
curl -X POST http://localhost:3001/api/onboarding/signup \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{
"email":"test@example.com",
"password":"<test-password>",
"code":"123456",
"data":{
"basic":{"dateOfBirth":"2012-04-23","gender":"FEMALE"},
"name":{"fullName":"Ada Lovelace"}
}
}'
# → save data.accessToken → $ACCESS_TOKEN (and the refreshToken)
# 2. Education — the first onboarding screen
curl -X POST http://localhost:3001/api/users/me/education \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{"schoolName":"Lincoln High","level":"high","grade":"10th","isCurrent":true}'
# 3. Catalogs for Screens 4-5 (cache locally)
curl "http://localhost:3001/api/users/topics?representative=1" \
-H "X-Client-Type: mobile" -H "Authorization: Bearer $ACCESS_TOKEN"
curl http://localhost:3001/api/intents \
-H "X-Client-Type: mobile" -H "Authorization: Bearer $ACCESS_TOKEN"
# 4. Screens 4-8 are normal calls against /api/users/me/interests,
# /api/intents/me, /api/users/me/avatar(/presign), /api/users/me/profile,
# /api/users/me/activities, /api/users/me/achievements — see Swagger UI.
# 5. Finished
curl -X POST http://localhost:3001/api/users/me/onboarding/complete \
-H "X-Client-Type: mobile" -H "Authorization: Bearer $ACCESS_TOKEN"
# 6. Prove the drop-off signal: log in and read the flag
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{"identifier":"test@example.com","password":"<test-password>"}'
# → data.user.onboardingCompleted