Skip to main content
Version: Latest
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.
"email":"test@example.com", "password":"Password123", "password":"<test-password>", "code":"123456",
-H "X-Client-Type: mobile" \ -d '{"identifier":"test@example.com","password":"Password123"}' -d '{"identifier":"test@example.com","password":"<test-password>"}'# → data.user.onboardingCompleted

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: mobile on 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/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. 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/send first. It returns 409 ACCOUNT_EXISTS before sending a code when the email already has an account, so send the user to login right there. Then send the 6-digit code as code in /signup. No code → 400 OTP_REQUIRED. phoneNumber → 400 PHONE_SIGNUP_DISABLED.
  • 2026-10-07 — Every user object here now also carries grade and school (both string | null, from the user's current school) next to isStudentVerified — 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=1 returns real names ("Travel & Exploration", "AI & Machine Learning" — no more lowercase with the & missing) and an icon on every one of the 86, icon_<slug with hyphens removed> (e.g. icon_travelexploration) with six exceptions below. Send the slug field 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 as 400 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_AGE as 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: mobile on 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.
  • /signup is not retry-safe the way /complete was. A second call with the same email is 409 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.
  • code is 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/signup with the code. 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:

TokenWhen you get itWhat it's for
accessTokenPOST /api/onboarding/signup responseEvery authenticated call — the rest of onboarding and all normal app usage
refreshTokenthe 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. /signup mints a device session itself, so the POST /api/auth/login step 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=1 is 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):

  1. Academics & Learning
  2. Technology & Innovation
  3. Business & Leadership
  4. Arts & Creativity
  5. Sports & Fitness
  6. Competitive Activities
  7. Gaming & Digital Culture
  8. Lifestyle
  9. 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.

code is required. Get it first with POST /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​

StatusCodeMeaningWhat to do
422MISSING_DOBdata.basic.dateOfBirth absentSend Screen 1's value; block Continue until it's set.
422INVALID_DOBunparseable dateFix the format (YYYY-MM-DD).
422INVALID_AGEage outside 13–25Show on the date-of-birth field.
409ACCOUNT_EXISTSemail already belongs to an accountSend the user to the login screen. Usually caught earlier, at /verification/send.
400OTP_REQUIREDno code, and no recent /verification/confirmSend the code first (see below).
400PHONE_SIGNUP_DISABLEDphoneNumber sentSign up with email only.
409UNIQUE_VIOLATIONthe chosen username was taken in the racePrompt for a different username and retry.
400INVALID_OTPwrong or expired codeLet the user retry, or send a new code.
429RATE_LIMIT_EXCEEDEDtoo many attemptsShow "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/send
    Content-Type: application/json
    X-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 code field in the POST /api/onboarding/signup body. The route confirms and consumes the code inline. Without it the signup is 400 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:

MethodPathUse
GET/api/users/me/educationList — prefill the screen when the user comes back
PUT/api/users/me/educationReplace the whole list: { "items": [ … ] }
PATCH/api/users/me/education/:idEdit one entry
DELETE/api/users/me/education/:idRemove 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.

ScreenEndpoint(s)
Screen 4 — InterestsPOST /api/users/me/interests (min 3 topicSlugs)
Screen 5 — IntentPUT /api/intents/me ({ intentSlugs })
Screen 6 — AvatarPOST /api/users/me/avatar/presign → PUT <uploadUrl> → PUT /api/users/me/avatar { avatarUrl }
Screen 6 — BioPATCH /api/users/me/profile { bio }
Screen 7 — ActivitiesMedia (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 — AchievementsSame 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:

  1. If you still hold a valid accessToken/refreshToken, call GET /api/users/me — it returns onboardingCompleted.
  2. Otherwise the user logs in: POST /api/auth/login returns data.user.onboardingCompleted in 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:

  1. GET /api/users/me — returns education[] and profile. Non-empty education ⇒ the education screen was completed; skip it.
  2. GET /api/users/me/interests, GET /api/intents/me — empty ⇒ that screen is still to do. (GET /api/users/me/activities and GET /api/users/me/achievements if 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​

MethodPathAuthBody
POST/api/onboarding/signupnone{ email, password, code, parentEmail?, data? } — creates the account, returns accessToken + refreshToken
POST/api/users/me/onboarding/completeaccessToken— 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/sendnone{ 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:

MethodPathReplaced by
POST/api/onboarding/sessionPOST /api/onboarding/signup
GET/api/onboarding/sessionGET /api/users/me
PATCH/api/onboarding/sessionthe individual screens' own endpoints
POST/api/onboarding/completePOST /api/onboarding/signup
DELETE/api/onboarding/sessionnothing — there is no staged session to abandon
POST/api/onboarding/media-presignPOST /api/users/me/avatar/presign, POST /api/media/presign
DELETE/api/onboarding/mediasame 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/validators package 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
⤓ Download .md