Skip to main content
Version: 1.0

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 the new multi-step registration flow 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).

Short version — just the API calls

The whole integration is six endpoints. Everything else is detail.

0. GET /api/users/topics → interest catalog (categories + subtopics)
GET /api/intents → intent catalog
1. POST /api/onboarding/session → onboardingToken
2. PATCH /api/onboarding/session (× N — one per checkpoint)
3. POST /api/onboarding/media-presign → { uploadUrl, publicUrl } (per file)
PUT <uploadUrl> (file bytes, direct to S3)
4. POST /api/onboarding/complete → { user, accessToken }
5. POST /api/auth/login → accessToken + refreshToken

The two catalog endpoints (0) accept either an accessToken (logged-in user) or the onboardingToken returned by step 1. Slugs are stable — feel free to cache responses locally for the session (server sends Cache-Control: max-age=3600).

media-presign accepts kind: "avatar" | "activity" | "achievement". Same endpoint for every onboarding upload — avatar, activity photos/videos, achievement photos/videos.

Auth headers:

  • 1, 5 — no auth.
  • 0, 2, 3, 4Authorization: Bearer <onboardingToken> (returned by 1). The catalog endpoints in (0) also accept a real accessToken if you have one.
  • PUT to S3 — no auth headers; the signed URL is the credential.
  • Send X-Client-Type: mobile on every API call except the S3 PUT.

Minimum bodies:

// 1. Create session — only `password` + one identifier are required.
{
"email": "kid@example.com", // OR "phoneNumber"
"password": "MyStr0ngPass",
"data": { // optional pre-fill
"basic": { "dateOfBirth": "2012-04-23", "gender": "FEMALE" },
"name": { "fullName": "Ada Lovelace" }
}
}

// 2. Checkpoint sync — partial deep-merge. Send only what changed.
// Arrays REPLACE, objects merge. Skip a screen ⇒ omit its key.
{ "data": { "interestSlugs": ["sports","music"] }, "checkpoint": "interests" }

// 3. Media presign — works for avatar, activity, achievement uploads.
{ "kind": "avatar", "filename": "avatar.jpg", "contentType": "image/jpeg" }
// then PUT bytes to uploadUrl with that exact Content-Type, then PATCH:
// - kind=avatar → data.profile.avatarUrl = publicUrl
// - kind=activity → push into data.activities[i].media[]
// - kind=achievement → push into data.achievements[i].media[]

// 4. Complete — only `data.basic.dateOfBirth` is required; everything else
// optional. Username auto-derives if absent.
{}

Rules of thumb:

  • Hold all screen state locally. Only PATCH at checkpoint boundaries — not on every keystroke.
  • data.basic.dateOfBirth is the only field /complete requires (plus an email/phone, already on the session).
  • /complete is idempotent — retry on network blips. A 200 with alreadyCompleted: true means it already ran; just store the token.
  • 401 on a PATCH ⇒ token expired ⇒ wipe local state, start over from #1.
  • OTP is parked. code is optional today. Don't send it.

Full walkthrough, schemas, error tables, and curl examples in the sections below.


TL;DR — wireframe screens mapped to API calls

Each wireframe screen below corresponds to one of three things: local-only (no backend hit), a POST /api/onboarding/session (only once — first server call), or a PATCH /api/onboarding/session (every subsequent checkpoint).

┌────────────────────────────────────────────────────────────────────────────┐
│ Screen 1 DOB + Gender │
│ → LOCAL ONLY. Hold values in app state. │
├────────────────────────────────────────────────────────────────────────────┤
│ Screen 2 First name + Last name + Email + Password │
│ → POST /api/onboarding/session │
│ Body bundles Screen 1 + Screen 2 inside `data`. │
│ Returns `onboardingToken` (Bearer JWT, 30-day TTL). │
├────────────────────────────────────────────────────────────────────────────┤
│ Screen 3 School / education │
│ → PATCH /api/onboarding/session checkpoint: "education" │
│ Screen 4 Interests (topic slugs) │
│ → PATCH /api/onboarding/session checkpoint: "interests" │
│ Screen 5 Intent (why join) │
│ → PATCH /api/onboarding/session checkpoint: "intent" │
│ Screen 6 Avatar + Bio │
│ → POST /api/onboarding/media-presign kind: "avatar" │
│ → PUT <uploadUrl> (binary, direct to S3) │
│ → PATCH /api/onboarding/session checkpoint: "profile" │
│ Screen 7 Activities (each with photos/videos) │
│ For each media file: │
│ POST /api/onboarding/media-presign kind: "activity" │
│ PUT <uploadUrl> │
│ → PATCH /api/onboarding/session checkpoint: "activities" │
│ Screen 8 Achievements (each with photos/videos) │
│ For each media file: │
│ POST /api/onboarding/media-presign kind: "achievement" │
│ PUT <uploadUrl> │
│ → PATCH /api/onboarding/session checkpoint: "achievements" │
├────────────────────────────────────────────────────────────────────────────┤
│ Final Review screen → POST /api/onboarding/complete │
│ → real User + accessToken returned. │
│ Follow up: POST /api/auth/login → refreshToken. │
└────────────────────────────────────────────────────────────────────────────┘

Rule of thumb: only Screen 2 and the avatar upload go to the network mid-flow. Everything else either stays local or PATCHes the session bag at a checkpoint boundary. Screen order is a guideline — you can reorder or add screens later without backend changes; just send the right keys in data.

Heads up — OTP / contact verification is parked for now. While the mobile flow is being built we are NOT sending or requiring a verification code. The endpoints (POST /api/verification/send + the code field on /onboarding/session) still exist — they're just optional. We'll turn this back on once the end-to-end flow is stable. See the "OTP is currently optional" callout below for the full picture.


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.

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 during onboarding

Two tokens are involved:

TokenWhen you get itWhen you use itHeader
onboardingTokenPOST /api/onboarding/session responseAll subsequent onboarding calls (GET / PATCH / complete / DELETE)Authorization: Bearer <onboardingToken>
accessTokenPOST /api/onboarding/complete responseAll authenticated API calls after registration finishesAuthorization: Bearer <accessToken>

Keep the onboardingToken in secure storage (Keychain on iOS / Keystore on Android). It's valid for 30 days — long enough for the user to abandon the flow on Day 1 and come back on Day 5.


2b. Interest + Intent catalogs

Don't hardcode the lists on the client — fetch them from the API. Both endpoints are dual-auth: send the onboardingToken from step 1 (preferred during signup) or an accessToken if the user is already logged in.

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

Returns the full interest taxonomy. 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 the server expects when you PATCH data.interestSlugs[].

GET /api/users/topics
Authorization: Bearer <onboardingToken | accessToken>
X-Client-Type: mobile

Response (truncated):

{
"success": true,
"data": [
{
"id": "...",
"slug": "stem",
"name": "STEM",
"category": "Academics & Learning",
"description": "Science, technology, engineering, math",
"icon": "beaker",
"order": 0
},
{
"id": "...",
"slug": "ai-machine-learning",
"name": "AI & Machine Learning",
"category": "Technology & Innovation",
"icon": "sparkles",
"order": 11
}
// ... ~38 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

Intents — GET /api/intents

GET /api/intents
Authorization: Bearer <onboardingToken | 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

Both selections sync via PATCH at their respective checkpoints — slug arrays only. At /complete the server resolves interest slugs to Topic rows (writes UserTopic) and maps intent slugs to the IntentKind enum stored as an array on User.intents:

PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{ "data": { "interestSlugs": ["ai-machine-learning","robotics","music"] }, "checkpoint": "interests" }
PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{ "data": { "intentSlugs": ["competitions","find-teammates"] }, "checkpoint": "intent" }

Unknown slugs are silently dropped at /complete. Validate against the fetched catalog before submit.


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 very next request.

Why: the session is created at Screen 2 (it needs the password). Saving a round-trip and keeping Screen 1 abort-friendly.

Screen 2 — Name + Email + Password → create the session

This is the first server call. Bundle Screen 1 + Screen 2 values into the request body. The server creates an OnboardingSession, hashes the password, and returns an onboardingToken used for everything that follows.

OTP is currently optional. Skip the code field. We'll wire OTP back on once the rest of the flow is stable. See "When we re-enable OTP" below if you're curious about the eventual shape.

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/session
Content-Type: application/json
X-Client-Type: mobile

{
"email": "kid@example.com", // OR "phoneNumber": "+15551234567"
"password": "MyStr0ngPass",
"parentEmail": "parent@example.com", // optional — only if minor
"data": {
"basic": {
"dateOfBirth": "2012-04-23", // from Screen 1
"gender": "FEMALE" // from Screen 1
},
"name": {
"fullName": "Ada Lovelace" // from Screen 2 (single Full Name input)
// — or send firstName/lastName separately if you collect them as two fields.
}
},
"checkpoint": "account-created" // optional analytics label
}

Response:

{
"success": true,
"data": {
"onboardingToken": "eyJhbGciOi...",
"session": {
"id": "...",
"data": { "basic": { "dateOfBirth": "2012-04-23", "gender": "FEMALE" } },
"reachedCheckpoints": ["identity"],
"lastCheckpoint": "identity",
"expiresAt": "2026-06-11T...",
"createdAt": "2026-05-12T..."
}
}
}

Store onboardingToken securely. Use it on every subsequent onboarding call.

Errors you'll see

StatusCodeMeaningWhat to do
400INVALID_OTPWrong code (only when OTP is enabled)Let user retry. After max attempts, restart from Screen 2.
409ACCOUNT_EXISTSEmail/phone already belongs to a real accountRedirect to login screen.
429RATE_LIMIT_EXCEEDEDToo many sends in 15 minShow "try later" message.

When we re-enable OTP (later)

Two extra steps slot in before the POST /api/onboarding/session call at Screen 2:

  • Send OTP (right after the user enters their email/phone on Screen 2)

    POST /api/verification/send
    Content-Type: application/json
    X-Client-Type: mobile

    {
    "channel": "email", // or "sms"
    "identifier": "kid@example.com", // email or E.164 phone number
    "purpose": "email_verification" // or "phone_verification"
    }

    Response: { "success": true, "data": { "expiresIn": 300 } }

    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/session body. The route will confirm and consume the code inline. We'll flip code from optional → required when this turns back on.


Screens 3+ — Collect everything else, sync at checkpoints

After Screen 2 you have a session. The user now moves through the remaining wireframe screens at their own pace. Hold this state in local app storage (e.g., AsyncStorage, MMKV, or Zustand persist). Don't hit the backend per screen.

A reasonable shape to mirror server expectations:

type OnboardingData = {
basic?: { dateOfBirth?: string; gender?: Gender };
name?: {
fullName?: string; // wireframe's single Full Name input — server splits at /complete
firstName?: string; // optional; wins over fullName split if sent
lastName?: string; // optional; wins over fullName split if sent
username?: string; // optional; auto-derived at /complete if absent
};
profile?: { displayName?: string; bio?: string; avatarUrl?: string };
education?: EducationItem[];
activities?: ActivityItem[];
achievements?: AchievementItem[];
interestSlugs?: string[]; // ["ai-machine-learning", "music", ...] — fetched from /api/users/topics
intentSlugs?: string[]; // ["competitions", "find-teammates", ...] — fetched from /api/intents
};

type Gender = "MALE" | "FEMALE" | "NON_BINARY" | "OTHER" | "PREFER_NOT_TO_SAY";

type EducationItem = {
schoolName: string;
level?: "elementary" | "middle" | "high" | "college" | "other";
grade?: string; // "5th", "10th"
startYear?: number;
endYear?: number;
isCurrent?: boolean;
city?: string;
country?: string;
order?: number; // for user-controlled sort
};

type InlineMediaItem = {
type: "image" | "video" | "gif";
url: string; // returned by /media-presign as publicUrl
thumbnailUrl?: string;
width?: number;
height?: number;
size?: number;
duration?: number; // seconds — for video
altText?: string;
};

type ActivityItem = {
title: string; // wireframe: "Activity name"
role?: string; // wireframe: "Role"
area?: string; // wireframe: "Area" (related field)
type?: string; // wireframe: "Type" (activity type)
organisation?: string; // wireframe: "Organisation/Club name"
duration?: string; // wireframe: "Duration" — free string "6 months"
category?: string; // legacy free string — prefer `area`
description?: string;
startDate?: string; // ISO date — wireframe: "Date"
endDate?: string;
isOngoing?: boolean;
location?: string;
media?: InlineMediaItem[]; // photos / videos for this activity
order?: number;
};

type AchievementItem = {
title: string; // wireframe: "Award/Competition name"
area?: string; // wireframe: "Area"
level?: string; // wireframe: "Level" — School | District | State | National
issuer?: string; // wireframe: "Issuing organisation"
awardedAt?: string; // ISO date — wireframe: "Date"
description?: string;
category?: string; // legacy free string — prefer `area`
media?: InlineMediaItem[]; // photos / videos for this achievement
order?: number;
};

Sync at checkpoints (NOT every screen)

When the user finishes a wireframe screen, send the bag with a checkpoint label.

PATCH /api/onboarding/session
Content-Type: application/json
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile

{
"data": {
"name": { "username": "ada_l" }
},
"checkpoint": "username"
}

Checkpoint names aligned to the wireframe screens

You pick the names. Server logs them for drop-off analytics. The wireframe- aligned set:

Wireframe screenCheckpoint nameWhat's in data
Screen 2 (server bootstrap)account-createdbasic, name.fullName (or name.firstName/lastName)
Username picker (optional, any time)usernamename.username — if omitted, server auto-derives at /complete
Screen 3 — Schooleducationeducation[]
Screen 4 — InterestsinterestsinterestSlugs[]
Screen 5 — IntentintentintentSlugs[]
Screen 6 — Avatar + Bioprofileprofile.avatarUrl, profile.bio (and optionally profile.displayName)
Screen 7 — Activitiesactivitiesactivities[]
Screen 8 — Achievementsachievementsachievements[]

Important rules:

  • Arrays replace, objects merge. If you send data.education, the entire list overwrites the stored one. So send the full list every time. For objects like data.profile, you can send just the keys you changed — the server merges per-key.
  • Don't sync after every keystroke. Send when the user taps "Continue" on a checkpoint screen.
  • Use undefined to skip, null to clear. Omitting a key leaves the stored value alone. Sending null for a single object key clears it.
  • Idempotent. If a PATCH fails (network), retry safely with the same payload.

Checking username availability live

Before the user submits a username, do a soft check:

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. The final complete call does a hard check.

Resuming after app close

On next launch, if you still have an onboardingToken and the user hasn't completed onboarding, re-hydrate from the server:

GET /api/onboarding/session
Authorization: Bearer <onboardingToken>

Response gives you the full data bag back plus reachedCheckpoints. Use it as the source of truth — overwrite your local cache.

If the response is 404 or 410 (expired), clear local state and start from Screen 1.


Media uploads (avatar, activity, achievement) — one endpoint

Every onboarding upload goes through POST /api/onboarding/media-presign. The body's kind field decides what's allowed and where the resulting publicUrl lands in the session:

kindAllowed content typesWhere the URL lives in data
avatarimage/jpeg, image/png, image/webpdata.profile.avatarUrl (string)
activityimage/* (as above) + video/mp4, video/quicktime, video/webmappend to data.activities[i].media[] (array)
achievementimage/* (as above) + video/mp4, video/quicktime, video/webmappend to data.achievements[i].media[] (array)

The general media flow (/api/media/presign) requires a real accessToken and won't work mid-onboarding. This route accepts the onboardingToken and stores objects under onboarding/<sessionId>/<kind>/... so the cleanup sweep can hard-delete all artifacts of an abandoned session.

Step A — Ask the API for a signed upload URL

POST /api/onboarding/media-presign
Content-Type: application/json
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile

{
"kind": "activity", // "avatar" | "activity" | "achievement"
"filename": "trophy.mp4",
"contentType": "video/mp4"
}

Response:

{
"success": true,
"data": {
"kind": "activity",
"uploadUrl": "https://minio.local/ksn-media/onboarding/<sessionId>/activity/...mp4?X-Amz-Signature=...",
"publicUrl": "https://minio.local/ksn-media/onboarding/<sessionId>/activity/...mp4",
"key": "onboarding/<sessionId>/activity/...mp4",
"contentType": "video/mp4"
}
}

uploadUrl is valid for ~5 minutes. Errors: 415 UNSUPPORTED_TYPE (content type isn't allowed for the given kind), 404 (session not active), 410 EXPIRED.

Step B — PUT the bytes directly to storage

Send the raw file body to uploadUrl. Do NOT add the Authorization or X-Client-Type headers on this request — it goes straight to S3/MinIO, which has its own auth baked into the signed URL.

PUT <uploadUrl>
Content-Type: video/mp4

<binary file bytes>

Use the same Content-Type you sent in Step A — the signature is bound to the content type. On success, S3/MinIO returns 200 with an empty body.

React Native example using fetch:

await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "video/mp4" },
body: fileBlob, // or `new Blob([bytes])`
});

Step C — Save the URL on the session

Avatar:

PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{
"data": { "profile": { "avatarUrl": "<publicUrl>" } },
"checkpoint": "profile"
}

Activity (Screen 7) — each activity has its own media[]:

PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{
"data": {
"activities": [
{
"title": "School band lead vocals",
"role": "Lead vocalist",
"area": "Music",
"type": "Group / club",
"organisation": "Lincoln High Music Club",
"duration": "2 years",
"startDate": "2024-04-01",
"description": "Performed at four events.",
"media": [
{ "type": "video", "url": "<publicUrl from media-presign>", "thumbnailUrl": "..." },
{ "type": "image", "url": "<publicUrl from media-presign>" }
]
}
]
},
"checkpoint": "activities"
}

Achievement (Screen 8) — each achievement has its own media[]:

PATCH /api/onboarding/session
Authorization: Bearer <onboardingToken>

{
"data": {
"achievements": [
{
"title": "Regional Math Olympiad — Gold",
"area": "Academic",
"level": "District",
"issuer": "Math Educators Association",
"awardedAt": "2025-11-22",
"description": "First place, ages 12–14 division.",
"media": [
{ "type": "image", "url": "<publicUrl from media-presign>" }
]
}
]
},
"checkpoint": "achievements"
}

Arrays REPLACE on PATCH. Send the full list of activities (or achievements) every time you sync this checkpoint — including their full media[] lists. If the user removes a file, drop it from the array.

Notes

  • If the user re-picks media, just repeat Steps A→C. Orphaned objects under onboarding/<sessionId>/ get GC'd by the cleanup sweep (TBD).
  • On /complete, the server commits Activity/Achievement rows and inserts their media into the polymorphic Media table — same table used for post and event media. Look up via ownerType + ownerId.

Final review screen — Complete

When the user finishes the last screen, commit:

POST /api/onboarding/complete
Content-Type: application/json
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile

{
"data": { /* optional final delta */ }
}

Response:

{
"success": true,
"data": {
"user": {
"id": "...",
"email": "kid@example.com",
"username": "ada_l",
"isMinor": true,
"requiresParentalConsent": false,
"createdAt": "2026-05-12T..."
},
"accessToken": "eyJhbGciOi..."
}
}

Required fields for /complete to succeed:

  • data.basic.dateOfBirth — must produce an age between 8 and 100
  • An identifier — email OR phoneNumber (already on the session from Screen 2)

Everything else is optional, including username. If the user hasn't picked a username by the time you hit /complete, the server auto-derives one from data.name.firstName (or the email local-part, or "user" as last resort), sanitized to [a-z0-9_] and probed against the DB so it doesn't collide. The user can edit their username later from the profile screen.

If displayName is missing, the server falls back to fullName, then to firstName, then to whatever username got used.

Name handling — the wireframe collects a single "Full name" input on Screen 2. Send it as data.name.fullName and the server will split it at /complete: first whitespace-delimited token → firstName, remainder → lastName. If you collect first/last as two separate fields, send those explicitly and they win over the fullName split.

Errors:

StatusCodeMeaning
422MISSING_DOBDOB wasn't set. Go back to Screen 1.
422INVALID_AGEDOB out of range (under 8, over 100).
409UNIQUE_VIOLATIONUsername got taken in the race between availability check and /complete. Prompt the user to pick a different one.
410EXPIREDSession past its 30-day TTL. Restart from Screen 1.

If /complete returns 200 (not 201) with alreadyCompleted: true, the user already completed this session — treat it the same way: store the access token and move on. (Happens on retry after a flaky network.)


After completion — Get a refresh token

/complete only gives you an accessToken. To get long-lived auth, call the existing login route with the same credentials. This is where the device session + refresh token get minted.

POST /api/auth/login
Content-Type: application/json
X-Client-Type: mobile

{
"identifier": "kid@example.com", // email or username
"password": "MyStr0ngPass"
}

Response includes both accessToken and refreshToken. Store them securely. After this, the user is fully authenticated — discard onboardingToken.


4. Common scenarios

User backgrounds the app mid-onboarding

Nothing to do server-side. Local state persists; resume from the same screen. Server state is intact for 30 days.

User switches devices mid-onboarding

The new device has no onboardingToken. They'd have to start over from Screen 1 — but the OnboardingSession for the same email is still ACTIVE. The backend will mark the prior session as ABANDONED when the new one is created. No data carries over (yet). Future enhancement: cross-device resume via email magic link.

User abandons onboarding

Just stop calling the API. After 30 days, the cleanup job marks the session ABANDONED. If you want to be explicit, call:

DELETE /api/onboarding/session
Authorization: Bearer <onboardingToken>

Server returns 401 on a PATCH

The onboardingToken is invalid or expired. Wipe local onboarding state and restart from Screen 1.

User wants to change email mid-flow

Not currently supported in a single session. Easiest path: DELETE the session and start over with the new email.


5. Reference — every endpoint

MethodPathAuthBody
POST/api/verification/sendnone{ channel, identifier, purpose }parked; skip for now
POST/api/onboarding/sessionnone{ email | phoneNumber, password, code?, parentEmail?, data?, checkpoint? }code optional during early dev
GET/api/onboarding/sessiononboardingToken
PATCH/api/onboarding/sessiononboardingToken{ data?, checkpoint? }
POST/api/onboarding/media-presignonboardingToken{ kind: "avatar" | "activity" | "achievement", filename, contentType } — returns { kind, uploadUrl, publicUrl, key, contentType }
POST/api/onboarding/completeonboardingToken{ data? }
DELETE/api/onboarding/sessiononboardingToken
GET/api/onboarding/username-available?u=...none
GET/api/users/topicsonboardingToken | access— Interest catalog (grouped by category). 30 req/min, 1h cacheable.
GET/api/intentsonboardingToken | access— Intent catalog. 30 req/min, 1h cacheable.
POST/api/auth/loginnone{ identifier, password }

All responses follow the standard envelope:

{ "success": true, "data": { ... } }

Errors:

{ "success": false, "error": "Human message", "code": "MACHINE_CODE" }

6. Questions / changes

If you need:

  • Cross-device resume (magic link)
  • Authenticated media upload during onboarding
  • A different OTP channel (WhatsApp, push)
  • Server-side runtime validators (shared @ksn/validators package on the mobile side)
  • New fields on Education, Activity, Achievement

… ping the backend team. Most things are additive — we can extend the data Json bag without breaking anything you've already shipped.


7. Quick test with curl

OTP is parked, so this happy-path skips /api/verification/send entirely. When OTP is turned back on, you'll add a code field to the session-create call below.

# 1. Start session — no OTP, no code field
curl -X POST http://localhost:3001/api/onboarding/session \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-d '{
"email":"test@example.com",
"password":"Password123",
"data":{
"basic":{"dateOfBirth":"2012-04-23","gender":"FEMALE"},
"name":{"fullName":"Ada Lovelace"}
},
"checkpoint":"account-created"
}'

# Save the onboardingToken from the response → $TOKEN

# 1b. Fetch interest + intent catalogs (cache locally)
curl http://localhost:3001/api/users/topics \
-H "X-Client-Type: mobile" \
-H "Authorization: Bearer $TOKEN"

curl http://localhost:3001/api/intents \
-H "X-Client-Type: mobile" \
-H "Authorization: Bearer $TOKEN"

# 2. Patch a checkpoint
curl -X PATCH http://localhost:3001/api/onboarding/session \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"data":{"name":{"username":"ada_l"}},
"checkpoint":"username"
}'

# 3. Get a presigned avatar upload URL
curl -X POST http://localhost:3001/api/onboarding/media-presign \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-H "Authorization: Bearer $TOKEN" \
-d '{"kind":"avatar","filename":"me.jpg","contentType":"image/jpeg"}'
# → grab `uploadUrl` and `publicUrl` from response → $UPLOAD_URL / $PUBLIC_URL

# 3b. PUT the file directly to storage (no auth headers — the URL is signed)
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
--data-binary @me.jpg

# 3c. Save the public URL back on the session
curl -X PATCH http://localhost:3001/api/onboarding/session \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"data\":{\"profile\":{\"avatarUrl\":\"$PUBLIC_URL\"}},\"checkpoint\":\"profile\"}"

# 4. Complete
curl -X POST http://localhost:3001/api/onboarding/complete \
-H "Content-Type: application/json" \
-H "X-Client-Type: mobile" \
-H "Authorization: Bearer $TOKEN" \
-d '{}'

# 5. Login to get refresh token
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":"Password123"}'

When OTP is enabled again

Two extra calls slot in before the session-create call (curl #1 above):

# 0a. Send OTP
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"}'

# 0b. Read the 6-digit code from the API logs (dev only — console channel)
# or from the email/SMS in real environments.

# Then the session-create call (curl #1) grows a `code` field:
# "code":"123456",