PONA AUTH V3
- NextGate — PONA Auth Flow V3 (Progressive · Onboarding · Native · Access)
- NextGate PONA Auth — EndPoint Doc (ACTIVE)
- PONA Auth Secondary Onboarding
NextGate — PONA Auth Flow V3 (Progressive · Onboarding · Native · Access)
Version 3.0 — The Complete Authentication Specification
Phone-first. Passwordless by default.
One flow. No walls. Trust earned progressively.
Philosophy
- One entry point — phone number only, always
- One auth system — no separate lite or hard auth flows
- Passwordless by default — password is an optional enhancement set later
- Progressive onboarding — primary is mandatory, secondary collected only when a resource needs it
- One token type — access token carries onboarding flags
- Persistent identity — device remembers who you are, you never type your number twice
- Channel choice — passwordless users pick where they receive their OTP
Token Types
| Token | Lifespan | Purpose | Issued At |
|---|---|---|---|
checkToken |
5 mins | Signed phone carrier — binds all auth actions to one account | /auth/check |
tempToken |
10 mins | OTP handshake only | /auth/start, /onboarding/email/initiate |
onboardingToken |
7 days | Primary flow only — unlocks name and age steps only | After OTP verified, primary incomplete |
accessToken |
1hr (no password) / 7 days (with password) | Full session, carries onboarding flags | After primary complete |
refreshToken |
30 days | Silent refresh, password users only, rotated on use | After password login |
What "Primary Complete" Means
Three requirements. All three done before access token is issued. No skip. No cancel.
✅ Phone verified via OTP
✅ First name + Last name set
✅ Date of birth set (age calculated → account tier assigned)
Account Tiers — Set at Age Step
| Age | Tier | What It Means |
|---|---|---|
| Under 13 | Blocked | Account deleted. Phone blocklisted. Cannot return until 13th birthday. |
| 13 — 17 | Restricted | Age-restricted content hidden. Some commerce limited. |
| 18+ | Full | No restrictions. |
Onboarding Flags (Inside Access Token)
Derived from actual account data. No separate database column needed.
| Flag | Means |
|---|---|
primaryComplete |
Phone verified + name set + date of birth set |
username |
Real username chosen — not a system temp one |
email |
Email submitted AND verified via OTP |
profilePic |
At least one profile picture uploaded |
interests |
At least 3 interests selected |
bio |
Bio text written |
Access Token Shape
{
"sub": "su_uuid",
"flags": {
"primaryComplete": true,
"username": false,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
},
"exp": "2026-04-01T12:00:00Z"
}
Resource Permission Matrix
| Feature | Needs Primary | Needs Secondary |
|---|---|---|
| Browse events / listings | ❌ No auth | — |
| React / like | ✅ | nothing extra |
| Buy ticket or product | ✅ | nothing extra |
| Share listing | ✅ | nothing extra |
| Comment publicly | ✅ | username |
| Follow someone | ✅ | username |
| Send a message | ✅ | username |
| Create an event | ✅ | username + email |
| Open a shop | ✅ | username + email |
| Sell a product | ✅ | username + email |
| Withdraw money | ✅ | username + email + profilePic |
| Age-restricted content | ✅ must be 18+ | nothing extra |
Secondary Field Priority Order
Backend returns missing fields one at a time in this order. User never sees all missing fields at once.
1 — username (needed for almost all social features)
2 — email (needed for commerce and trust)
3 — profilePic (needed for high-trust actions)
4 — bio (rarely hard-required)
5 — interests (feed personalization, almost never hard-required)
Auth Method Validation
Every auth endpoint validates the user has the method they are trying to use.
| Endpoint | Validation |
|---|---|
/auth/login/password |
Account must have password set |
/auth/login/oauth Google |
Google must be linked to this account |
/auth/login/oauth Apple |
Apple must be linked to this account |
/auth/password/forgot/initiate |
Account must have password set |
/auth/passwordless/channels |
Always allowed |
/auth/start OTP |
Always allowed — passwordless available to everyone |
OTP Channel Selection
Passwordless users with email set can choose where to receive their OTP. Frontend never passes the raw email or phone — only the channel type enum.
Channel Availability Rules
| Channel | Available When |
|---|---|
PHONE |
Always — phone is primary, always verified |
EMAIL |
Only when email is set AND verified on the account |
Action Codes — Complete Reference
| Action Code | What Frontend Does |
|---|---|
REGISTER |
New user — show registration intro |
CONTINUE_ONBOARDING |
Returning user, primary incomplete — resume |
LOGIN |
Account ready — show auth method options |
RESTART_AUTH |
Token expired — back to phone entry |
SELECT_CHANNEL |
Multiple OTP channels — show picker |
PROCEED_TO_OTP |
Single channel only — skip picker, go straight to OTP |
USE_OTP |
Wrong auth method chosen — switch to OTP |
RETRY_OTP |
Wrong OTP — error on same screen |
RESEND_OTP |
OTP expired — activate resend |
WAIT |
Rate limited — show countdown |
ACCOUNT_BLOCKED |
Under 13 — show blocked screen |
COLLECT_USERNAME |
Username needed |
COLLECT_EMAIL |
Email needed — submit then OTP verify |
COLLECT_PROFILE_PIC |
Profile picture needed |
COLLECT_INTERESTS |
Interests needed |
COLLECT_BIO |
Bio needed |
PROCEED |
All steps done — retry original action |
Response Shapes
Success
{
"success": true,
"message": "Human readable message",
"action": "NEXT_ACTION_OR_NULL",
"data": { }
}
Error — HTTP 422
{
"success": false,
"message": "Human readable message",
"action": "NEXT_ACTION_CODE",
"context": "what_user_was_trying_to_do",
"data": { }
}
Response Examples
/auth/check — New User
{
"success": true,
"message": "Phone number not registered",
"action": "REGISTER",
"data": { "exists": false, "checkToken": null }
}
/auth/check — Existing User Ready
{
"success": true,
"message": "Welcome back",
"action": "LOGIN",
"data": {
"exists": true,
"checkToken": "eyJ...",
"primaryComplete": true,
"maskedPhone": "••• ••• ••78",
"authMethods": {
"passwordless": true,
"password": true,
"google": true,
"apple": false
}
}
}
/auth/check — Primary Incomplete
{
"success": true,
"message": "Continue setting up your account",
"action": "CONTINUE_ONBOARDING",
"data": {
"exists": true,
"checkToken": "eyJ...",
"primaryComplete": false,
"maskedPhone": "••• ••• ••78"
}
}
/auth/passwordless/channels — Multiple Channels
{
"success": true,
"message": "Choose where to receive your code",
"action": "SELECT_CHANNEL",
"data": {
"channels": [
{ "type": "PHONE", "masked": "••• ••• ••78", "isPrimary": true },
{ "type": "EMAIL", "masked": "j••••@g••••.com", "isPrimary": false }
]
}
}
/auth/passwordless/channels — Single Channel Only
{
"success": true,
"message": "Sending code to your phone",
"action": "PROCEED_TO_OTP",
"data": {
"channels": [
{ "type": "PHONE", "masked": "••• ••• ••78", "isPrimary": true }
]
}
}
/auth/start — OTP Sent
{
"success": true,
"message": "Verification code sent",
"action": null,
"data": {
"tempToken": "eyJ...",
"maskedDestination": "••• ••• ••78",
"channel": "PHONE",
"expiresInSeconds": 120,
"resendAvailableAfterSeconds": 60
}
}
/auth/verify — Primary Incomplete
{
"success": true,
"message": "Phone verified. Let us set up your account.",
"action": "COLLECT_PRIMARY",
"data": {
"onboardingToken": "eyJ...",
"nextStep": "name"
}
}
/auth/verify — Primary Already Complete
{
"success": true,
"message": "Welcome back!",
"action": null,
"data": {
"accessToken": "eyJ...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
}
}
}
/auth/onboarding/age — Blocked Underage
{
"success": false,
"message": "You must be at least 13 years old to use NextGate",
"action": "ACCOUNT_BLOCKED",
"context": "underage",
"data": { "unblockDate": "2027-06-15" }
}
/auth/onboarding/age — Primary Complete
{
"success": true,
"message": "Welcome to NextGate!",
"action": null,
"data": {
"accessToken": "eyJ...",
"accountTier": "FULL",
"onboarding": {
"primaryComplete": true,
"username": false,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
}
}
}
OTP Wrong
{
"success": false,
"message": "Incorrect OTP code",
"action": "RETRY_OTP",
"context": "otp_verify",
"data": { "attemptsRemaining": 2 }
}
OTP Expired
{
"success": false,
"message": "OTP has expired",
"action": "RESEND_OTP",
"context": "otp_expired",
"data": { "resendAvailable": true, "resendCooldownSeconds": 0 }
}
Rate Limited
{
"success": false,
"message": "Too many attempts. Please wait.",
"action": "WAIT",
"context": "rate_limited",
"data": { "retryAfterSeconds": 120 }
}
Wrong Auth Method
{
"success": false,
"message": "This account does not use password login",
"action": "USE_OTP",
"context": "password_login",
"data": { "availableMethods": ["passwordless", "google"] }
}
Secondary Gate — Multiple Missing
{
"success": false,
"message": "A couple of things needed before you can create events",
"action": "COLLECT_USERNAME",
"context": "create_event",
"data": {
"currentMissing": "username",
"allMissing": ["username", "email"],
"stepsRemaining": 2
}
}
Secondary Step Done — Next Signalled
{
"success": true,
"message": "Username set. One more step.",
"action": "COLLECT_EMAIL",
"context": "create_event",
"data": {
"accessToken": "eyJ...",
"nextMissing": "email",
"stepsRemaining": 1,
"onboarding": {
"primaryComplete": true,
"username": true,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
}
}
}
All Secondary Done — Proceed
{
"success": true,
"message": "All done. Creating your event now.",
"action": "PROCEED",
"context": "create_event",
"data": {
"accessToken": "eyJ...",
"stepsRemaining": 0,
"onboarding": {
"primaryComplete": true,
"username": true,
"email": true,
"profilePic": false,
"interests": false,
"bio": false
}
}
}
Forgot Password — Reset Complete
{
"success": true,
"message": "Password updated. All other sessions signed out.",
"action": null,
"data": { "accessToken": "eyJ..." }
}
Flow Diagrams
FLOW 1 — App Open with Stored Accounts
┌─────────────────────────────────────────────────────┐
│ App opens │
└──────────────────────┬──────────────────────────────┘
│
▼
Read device secure storage
for stored accounts list
│
┌──────────┴──────────┐
│ │
NO ACCOUNTS ACCOUNTS FOUND
│ │
▼ ▼
Show clean phone Count stored accounts
entry screen │
┌────────┴────────┐
ONE MULTIPLE
│ │
▼ ▼
Auto-call Show account
/auth/check picker screen
in background User taps one
│ │
└────────┬────────┘
▼
/auth/check called
for that identifier
│
▼
Show personalized
welcome screen with
auth method buttons
FLOW 2 — Auth Check (Entry Point)
┌─────────────────────────────────────────────────────┐
│ POST /auth/check │
│ { "identifier": "+255712345678" } │
└──────────────────────┬──────────────────────────────┘
│
▼
Valid phone format?
│
┌──────────┴──────────┐
NO YES
│ │
▼ ▼
422 Look up in database
Invalid │
phone ┌────────┴────────┐
│ │
NOT FOUND FOUND
│ │
▼ ▼
action: REGISTER Phone verified?
checkToken: null ┌──────┴──────┐
NO YES
│ │
▼ ▼
Release phone Primary complete?
from orphan ┌──────┴──────┐
action: REGISTER NO YES
│ │
▼ ▼
action: action: LOGIN
CONTINUE_ authMethods
ONBOARDING returned
│ │
└──────┬───────┘
▼
checkToken issued
containing { identifier }
stored to device on success
FLOW 3 — New User Registration
action: REGISTER from /auth/check
............................................
┌─────────────────────────────────────────────────────┐
│ POST /auth/start │
│ { "checkToken": "eyJ...", "channel": "PHONE" } │
└──────────────────────┬──────────────────────────────┘
│
▼
Phone extracted from checkToken
Partial account created
OTP sent via SMS
tempToken issued
│
▼
┌─────────────────────────────────────────────────────┐
│ POST /auth/verify │
│ { "tempToken": "eyJ...", "otp": "123456" } │
└──────────────────────┬──────────────────────────────┘
│
▼
Phone verified
Primary incomplete
ONBOARDING TOKEN issued
App locked to primary screens
│
┌──────────┴──────────┐
▼ ▼
POST /auth/ POST /auth/
onboarding/name onboarding/age
{ firstName, { birthDate }
lastName } │
│ ▼
▼ Under 13? → BLOCKED
New onboarding 13-17 → RESTRICTED
token returned 18+ → FULL
Continue to age │
▼
PRIMARY COMPLETE
ACCESS TOKEN issued
Identifier + name + avatar
saved to device storage
User lands on feed ✓
FLOW 4 — Existing User, Passwordless Login
action: LOGIN, authMethods.passwordless: true
User picks OTP option
............................................
┌─────────────────────────────────────────────────────┐
│ POST /auth/passwordless/channels │
│ { "checkToken": "eyJ..." } │
└──────────────────────┬──────────────────────────────┘
│
▼
Backend checks account channels
│
┌──────────┴──────────┐
│ │
ONE CHANNEL MULTIPLE CHANNELS
(phone only) (phone + email)
│ │
▼ ▼
action: action: SELECT_CHANNEL
PROCEED_TO_OTP Show channel picker
Skip picker User picks PHONE or EMAIL
│ │
└──────────┬──────────┘
▼
┌─────────────────────────────────────────────────────┐
│ POST /auth/start │
│ { "checkToken": "eyJ...", "channel": "PHONE" } │
│ or { "checkToken": "eyJ...", "channel": "EMAIL" } │
└──────────────────────┬──────────────────────────────┘
│
▼
Backend extracts actual phone or email
internally from account
Sends OTP to chosen channel
tempToken issued
│
▼
POST /auth/verify { tempToken, otp }
│
▼
OTP valid. Primary complete.
ACCESS TOKEN issued.
Device storage entry updated.
User lands on feed ✓
FLOW 5 — Existing User, Password Login
action: LOGIN, authMethods.password: true
User picks password option
............................................
┌─────────────────────────────────────────────────────┐
│ POST /auth/login/password │
│ { "checkToken": "eyJ...", "password": "..." } │
└──────────────────────┬──────────────────────────────┘
│
▼
Account found from checkToken
│
┌──────────┴──────────┐
NO PASSWORD HAS PASSWORD
│ │
▼ ▼
422 Password verified
action: USE_OTP Risk assessed
availableMethods │
returned ┌───────┴───────┐
│ │
KNOWN DEVICE UNKNOWN DEVICE
│ │
▼ ▼
ACCESS TOKEN Device OTP sent
issued Verify device
directly ACCESS TOKEN issued
FLOW 6 — OAuth Login
action: LOGIN, authMethods.google: true
User picks Google
............................................
┌─────────────────────────────────────────────────────┐
│ POST /auth/login/oauth │
│ { "checkToken": "eyJ...", │
│ "provider": "GOOGLE", "code": "..." } │
└──────────────────────┬──────────────────────────────┘
│
▼
Account found from checkToken
Google linked to account?
│
┌──────────┴──────────┐
NO YES
│ │
▼ ▼
422 Google identity confirmed
action: USE_OTP Profile data pre-filled
availableMethods from Google
returned │
Primary complete?
┌────────┴────────┐
NO YES
│ │
▼ ▼
ONBOARDING TOKEN ACCESS TOKEN
collect age issued ✓
FLOW 7 — Forgot Password
Only shown when authMethods.password: true
............................................
┌─────────────────────────────────────────────────────┐
│ POST /auth/password/forgot/initiate │
│ { "checkToken": "eyJ..." } │
└──────────────────────┬──────────────────────────────┘
│
▼
Account has password?
│
┌──────────┴──────────┐
NO YES
│ │
▼ ▼
422 OTP sent to phone
action: USE_OTP tempToken issued
│
▼
POST /auth/password/forgot/verify-otp
{ tempToken, otp }
│
▼
OTP verified
resetToken issued (10 mins, single use)
│
▼
POST /auth/password/forgot/reset
{ resetToken, newPassword, confirmPassword }
│
▼
Password updated
All other sessions revoked
ACCESS TOKEN issued
User logged in ✓
FLOW 8 — Secondary Onboarding (Progressive)
User tries to create an event
Needs: username + email
username: false ← first missing
email: false
............................................
422 from resource guard
action: COLLECT_USERNAME
allMissing: ["username", "email"]
stepsRemaining: 2
............................................
Frontend: "2 steps — Step 1 of 2"
POST /onboarding/username
Bearer <accessToken>
{ "username": "joshsakweli" }
│
▼
Username saved
New accessToken issued
action: COLLECT_EMAIL
stepsRemaining: 1
............................................
Frontend: "Step 2 of 2 — Add email"
POST /onboarding/email/initiate
Bearer <accessToken>
{ "email": "josh@qbitspark.com" }
│
▼
OTP sent to email
tempToken returned
nextAction: VERIFY_EMAIL
│
▼
POST /onboarding/email/verify
Bearer <accessToken>
{ "tempToken": "eyJ...", "otp": "123456" }
│
▼
Email verified
New accessToken issued
action: PROCEED
stepsRemaining: 0
│
▼
Frontend retries create event
Passes ✓
FLOW 9 — Wrong Number, Changing During Registration
User typed wrong number
OTP sent. User clicks "Change number"
Before OTP verified — just restart
............................................
POST /auth/check { correct number }
│
┌──────┴──────────────────┐
│ │
NOT IN DB ALREADY IN DB
│ │
▼ ▼
Fresh Phone verified?
registration ┌──────┴──────┐
continues NO YES
│ │
▼ ▼
Release phone Primary complete?
from orphan ┌──────┴──────┐
New user NO YES
flow │ │
CONTINUE_ "Number has account.
ONBOARDING Login instead?"
│
┌────────┴────────┐
LOGIN DIFFERENT
│ NUMBER
▼ ▼
Login flow /auth/check
again
FLOW 10 — Returning User, Token Expired
App opened. Access token expired.
............................................
│
┌──────────┴──────────┐
│ │
HAS PASSWORD NO PASSWORD
│ │
▼ ▼
Has refresh token? /auth/check auto-called
┌───────┴───────┐ from stored identifier
YES NO │
│ │ ▼
▼ ▼ Passwordless channel check
Silent Show OTP sent to chosen channel
refresh login /auth/verify
ACCESS screen Primary complete → ACCESS TOKEN
TOKEN directly, no onboarding shown ✓
issued ✓
Client-Side Persistent Identity
This is a frontend-only feature. Zero backend changes required.
What Gets Stored on Device
┌────────────────────────────────────────────────────┐
│ Stored after every successful login │
│ │
│ identifier → "+255712345678" │
│ maskedPhone → "••• ••• ••78" │
│ displayName → "Joshua Sakweli" │
│ avatarUrl → "https://..." │
│ lastLoginAt → "2026-04-01T10:00:00Z" │
└────────────────────────────────────────────────────┘
NEVER store:
✗ Access tokens
✗ Refresh tokens
✗ Passwords or OTPs
✗ Full unmasked phone number in plain text
Storage Location by Platform
| Platform | Storage Method |
|---|---|
| Android | EncryptedSharedPreferences — hardware-backed encryption |
| iOS | Keychain — secure enclave |
| Web | localStorage — for non-sensitive display data only, never tokens |
Stored Accounts List Rules
Maximum 5 accounts stored per device
Sorted by lastLoginAt — most recently used first
Updated after every successful login (name, avatar may change)
If 6th account added → prompt user to remove one first
UI Screens (Dotted)
Screen 1 — App Open, One Stored Account
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
[NextGate Logo]
┌─────────────────────┐
│ [ Avatar ] │
│ Joshua Sakweli │
│ ••• ••• ••78 │
└─────────────────────┘
┌─────────────────────┐
│ Continue with OTP │ ← primary option
└─────────────────────┘
┌─────────────────────┐
│ Use Password │ ← only if password set
└─────────────────────┘
┌─────────────────────┐
│ G Continue with │ ← only if google linked
│ Google │
└─────────────────────┘
Not you? Sign in with a different account
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 2 — Account Picker (Multiple Stored Accounts)
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
[NextGate Logo]
Choose an account
┌─────────────────────────┐
│ [Av] Joshua Sakweli →│ ← tap to login
│ ••• ••• ••78 │
│ 2 mins ago │
├─────────────────────────┤
│ [Av] QBIT SPARK →│
│ ••• ••• ••32 │
│ 3 days ago │
├─────────────────────────┤
│ + Add another account│
└─────────────────────────┘
Long press an account to remove it
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 3 — Remove Account Confirmation
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Remove account from
this device?
┌─────────────────────┐
│ [Av] Joshua Sakweli│
│ ••• ••• ••78 │
└─────────────────────┘
This only removes the account
from this device. Your NextGate
account will not be deleted.
┌─────────────────────┐
│ Remove │
└─────────────────────┘
┌─────────────────────┐
│ Cancel │
└─────────────────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 4 — Fresh Phone Entry (No Stored Account)
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
[NextGate Logo]
Enter your phone number
to get started
┌──────┐ ┌───────────────┐
│ +255 │ │ 7XX XXX XXX │
└──────┘ └───────────────┘
┌─────────────────────┐
│ Continue │
└─────────────────────┘
By continuing you agree to our
Terms of Service and Privacy Policy
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 5 — OTP Channel Picker
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Where should we send
your code?
┌─────────────────────────┐
│ 📱 SMS to │
│ ••• ••• ••78 │ ← tap to choose
└─────────────────────────┘
┌─────────────────────────┐
│ ✉️ Email to │
│ j••••@g••••.com │ ← tap to choose
└─────────────────────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 6 — OTP Entry
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Enter the 6-digit code
sent to ••• ••• ••78
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ 1 │ │ 2 │ │ 3 │ │ │ │ │ │ │
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘
Code expires in 01:47
Resend code (available in 0:13)
Wrong number? Change it
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 7 — Primary Onboarding, Name Step
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
● ○ Step 1 of 2
What is your name?
┌─────────────────────────┐
│ First name │
└─────────────────────────┘
┌─────────────────────────┐
│ Last name │
└─────────────────────────┘
┌─────────────────────┐
│ Continue │
└─────────────────────┘
This is how you will appear
on NextGate
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 8 — Primary Onboarding, Age Step
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
● ● Step 2 of 2
When were you born?
┌──────────────────────────┐
│ DD / MM / YYYY │
└──────────────────────────┘
┌─────────────────────┐
│ Continue │
└─────────────────────┘
Your age helps us show you
the right content.
We never share your birthday.
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Screen 9 — Secondary Onboarding Gate (Inline, Not Full Screen)
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
╔═════════════════════════╗
║ Choose a username ║
║ to create events ║
║ ║
║ Step 1 of 2 ║
║ ────────────── ║
║ ║
║ ┌─────────────────┐ ║
║ │ @username │ ║
║ └─────────────────┘ ║
║ ║
║ ┌─────────────────┐ ║
║ │ Continue │ ║
║ └─────────────────┘ ║
║ ║
║ Maybe later ║ ← dismisses modal
╚═════════════════════════╝ user stays on feed
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Secondary onboarding appears as a bottom sheet or modal, not a full page. User can dismiss it and continue browsing. They will be prompted again when they try the same action.
Screen 10 — Forgot Password
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Forgot your password?
We will send a reset code to
your phone number.
┌─────────────────────┐
│ Send reset code │
└─────────────────────┘
┌─────────────────────┐
│ Login with OTP │ ← always available
└─────────────────────┘
Code will be sent to
••• ••• ••78
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Endpoint Reference
Public — No Auth Required
| Method | Endpoint | Send | Receive |
|---|---|---|---|
| POST | /auth/check |
{ identifier } |
checkToken + exists + authMethods |
| POST | /auth/passwordless/channels |
{ checkToken } |
available channels masked |
| POST | /auth/start |
{ checkToken, channel } |
tempToken |
| POST | /auth/verify |
{ tempToken, otp } |
onboardingToken or accessToken |
| POST | /auth/login/password |
{ checkToken, password } |
accessToken or device flow |
| POST | /auth/login/oauth |
{ checkToken, provider, code } |
accessToken or onboardingToken |
| POST | /auth/resend-otp |
{ tempToken } |
new tempToken |
| POST | /auth/device/verify |
{ deviceVerificationToken, otp } |
accessToken |
| POST | /auth/password/forgot/initiate |
{ checkToken } |
tempToken |
| POST | /auth/password/forgot/verify-otp |
{ tempToken, otp } |
resetToken |
| POST | /auth/password/forgot/reset |
{ resetToken, newPassword, confirmPassword } |
accessToken |
Primary Onboarding — Onboarding Token Required
| Method | Endpoint | Send | Receive |
|---|---|---|---|
| POST | /auth/onboarding/name |
{ onboardingToken, firstName, lastName } |
new onboardingToken |
| POST | /auth/onboarding/age |
{ onboardingToken, birthDate } |
accessToken |
Secondary Onboarding — Access Token Required
| Method | Endpoint | Send | Receive |
|---|---|---|---|
| POST | /onboarding/username |
{ username } |
new accessToken + next action |
| POST | /onboarding/bio |
{ bio } |
new accessToken + next action |
| POST | /onboarding/interests |
{ interestIds[] } |
new accessToken + next action |
| POST | /onboarding/profile-pic |
multipart image | new accessToken + next action |
| POST | /onboarding/email/initiate |
{ email } |
tempToken + nextAction |
| POST | /onboarding/email/verify |
{ tempToken, otp } |
new accessToken + next action |
Session Management — Access Token Required
| Method | Endpoint | Send | Receive |
|---|---|---|---|
| POST | /auth/token/refresh |
{ refreshToken } |
new accessToken + refreshToken |
| POST | /auth/token/revoke |
{ refreshToken } |
success |
| POST | /auth/sessions/sign-out |
— | success |
| GET | /auth/sessions |
— | active sessions list |
| DELETE | /auth/sessions/{id} |
— | success |
Client-Side Storage Specification
Storage Keys
ng_stored_accounts → JSON array of stored account objects
ng_active_identifier → identifier of currently active session
Stored Account Object
{
"identifier": "+255712345678",
"maskedPhone": "••• ••• ••78",
"displayName": "Joshua Sakweli",
"avatarUrl": "https://cdn.nextgate.app/avatars/...",
"lastLoginAt": "2026-04-01T10:00:00Z"
}
Account Management Rules
| Action | What Happens |
|---|---|
| Successful login | Add or update entry in stored list. Update lastLoginAt, name, avatar. |
| Normal logout | Keep entry in stored list. User sees welcome back on next visit. |
| "Forget this device" logout | Remove entry from stored list. Clean phone entry shown next visit. |
| Remove from picker | Remove entry from stored list. Account still exists on server. |
| Add another account | Login flow, auto-added to list on success. |
| 6th account added | Prompt user to remove one existing entry first. |
| Account deleted on server | Remove entry from stored list automatically after next failed check. |
What to Update After Successful Login
After ACCESS TOKEN received:
→ Update displayName from onboarding flags if changed
→ Update avatarUrl if changed
→ Update lastLoginAt to now
→ Sort stored list by lastLoginAt descending
What Changes vs What Stays
Being Removed
- Single linear
OnboardingSteptracking → replaced by independent flags onboardingStepdatabase column → database migration requiredisOnboardingComplete()→ replaced byisPrimaryComplete()- Onboarding token for secondary steps → access token handles all of that now
refreshOnboardingTokenendpoint → no longer needed- Email and username as login identifiers → phone only from now on
- Raw identifier passed to
/auth/start→ replaced bycheckToken+channel
Being Added
POST /auth/check— new entry pointPOST /auth/passwordless/channels— new channel check endpointOnboardingFlagResolver— derives all flags from existing account data- Resource guard — checks flags, returns next action automatically
checkTokengeneration in JWT systemchannelfield on/auth/start- All secondary onboarding endpoints
actionandcontexton all responses- Client-side persistent identity (frontend only, zero backend changes)
Staying Exactly as They Are
- All OTP generation, validation, and rate limiting
- Session creation and management
- Device trust and registration
- Risk assessment and scoring
- Account blocking for underage users and fraud
- Password change and management
- Email and phone account linking (post-login)
- JWT signing infrastructure
- Security filter chain — minor flag reading addition only
Security Notes
/auth/checkrate limited — max 10 per IP per minute, max 3 per phone per hourcheckTokensingle-use — consumed the moment any auth action is takencheckTokencryptographically binds the phone to every action — identifier cannot be swapped mid-flow- Frontend never passes raw email or phone after
/auth/check— channel type enum only - Orphaned partial accounts cleaned up automatically every night
- Phone collision with verified account — redirected to login, cannot overwrite
- Phone collision with unverified account — released and reassigned via OTP proof
- Primary onboarding — no cancel, no skip, app stays locked until all three steps done
- Underage — account deleted immediately, phone blocklisted, cannot return until 13th birthday
- Forgot password link — never shown unless
authMethods.password: true - Wrong auth method — backend validates before doing anything, 422 returned immediately
- Stored accounts on device — only display data stored, never tokens or passwords
- "Forget this device" — clears stored identifier, forces fresh phone entry next visit
NextGate PONA Auth — EndPoint Doc (ACTIVE)
For more details on the full flow design: PONA Auth v3 Design Doc
What is PONA Auth?
Progressive · Onboarding · Native · Access
PONA Auth is NextGate's unified, phone-first authentication system. It replaces all legacy auth flows with a single coherent pipeline. Core philosophy:
- Phone is the primary identifier — always. Every account starts with a verified phone number. No exceptions.
- Passwordless by default — users authenticate via OTP. Password and OAuth are optional enhancements added post-registration.
- Progressive onboarding — only the bare minimum is collected upfront (phone + name + birthdate). Everything else (username, email, bio, interests, profile pic) is collected lazily when the feature needs it.
- One flow, two outcomes — the same endpoints serve both new and returning users. The server decides what happens based on account state.
Token types at a glance
| Token | Expiry | Purpose |
|---|---|---|
checkToken |
10 min | Proves a phone check was made. Single-use. |
tempToken |
15 min | Carries the OTP session. Single-use after verify. |
onboardingToken |
1 hour | Issued after OTP verify for new users. Unlocks primary onboarding. |
accessToken |
1 hour | Standard bearer token. Attached to every protected request. |
refreshToken |
30 days | Rotates on use. Used to get a new accessToken silently. |
Secure storage — frontend requirements
Store tokens incorrectly and the whole auth system is compromised.
| Token | Where to store | Why |
|---|---|---|
accessToken |
In-memory only (React state, Zustand, etc.) | Never localStorage — XSS can steal it |
refreshToken |
HttpOnly cookie (web) / Secure Keychain (mobile) | Never localStorage or AsyncStorage directly |
onboardingToken |
In-memory only | Short-lived, no need to persist |
checkToken |
In-memory only | Single-use, discard after consuming |
tempToken |
In-memory only | Single-use, discard after OTP verify |
Standard Response Format
Success response
{
"success": true,
"httpStatus": "OK",
"message": "Human-readable message",
"action": "ACTION_CODE",
"action_time": "2026-04-03T10:30:45",
"data": {}
}
Error response
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Error description",
"action_time": "2026-04-03T10:30:45",
"data": "Error description"
}
Action codes
| Code | Meaning | Next step |
|---|---|---|
REGISTER |
Phone not found — new user | Show registration UI, proceed to channels |
LOGIN |
Phone found — existing user | Show login UI, proceed to channels |
CONTINUE_ONBOARDING |
Phone found but primary incomplete | Proceed to channels → onboarding |
PROCEED_TO_OTP |
Only one channel available | Skip channel picker, send OTP automatically |
SELECT_CHANNEL |
Multiple channels available | Show channel picker to user |
COLLECT_PRIMARY |
OTP verified, primary data needed | Show name + birthdate form |
ACCOUNT_BLOCKED |
User is underage or blocked | Show blocked message with unblock date |
VERIFY_DEVICE |
Unknown device on password login | Show device OTP verification |
OTP Channels
OTP can be delivered via the following channels. Not all channels are available in every situation — the server enforces the rules.
Available channel values
| Value | Description | User selectable |
|---|---|---|
SMS |
OTP delivered via SMS | ✅ |
WHATSAPP |
OTP delivered via WhatsApp | ✅ |
SMS_AND_WHATSAPP |
OTP sent to both SMS and WhatsApp simultaneously | ✅ |
EMAIL |
OTP delivered via email | ✅ |
SMS_AND_WHATSAPPfires both sends in parallel on the server. The user gets the OTP on both channels at the same time. If one channel fails, the other still delivers.
EMAIL_AND_WHATSAPP,EMAIL_AND_SMS,ALL_CHANNELSare internal server-side values. Never send these from the client — they will be rejected.
Channel rules by purpose
| Channel | New user (registration) | Existing user (login) |
|---|---|---|
SMS |
✅ | ✅ |
WHATSAPP |
✅ | ✅ |
SMS_AND_WHATSAPP |
✅ | ✅ |
EMAIL |
❌ not allowed | ✅ only if account has a verified email |
Channel request examples
Send via SMS only:
{ "channel": "SMS" }
Send via WhatsApp only:
{ "channel": "WHATSAPP" }
Send via both SMS and WhatsApp at the same time:
{ "channel": "SMS_AND_WHATSAPP" }
Send via email (login only, verified email required):
{ "channel": "EMAIL" }
Shared Objects
UserInfo
Returned by /auth/verify-otp and /auth/onboarding/primary once the user is identified. Frontend devs should persist this in local storage for display use (profile header, greetings, etc.).
{
"displayName": "Joshua Sakweli",
"phone": "+255745051250",
"maskedPhone": "••• ••• ••50",
"avatarUrl": "https://cdn.example.com/avatars/uuid.jpg"
}
| Field | Type | Description |
|---|---|---|
displayName |
string | null | First + last name. Null until primary onboarding is complete. |
phone |
string | Full unmasked phone in international format. Always present. Safe to store — it is the user's own number, just verified via OTP. |
maskedPhone |
string | Masked phone for visible UI display (e.g. "••• ••• ••50"). Always present. |
avatarUrl |
string | null | URL of the user's profile picture. Null until a profile picture is uploaded. |
Storage guidance:
phone,maskedPhone,displayName, andavatarUrlare display data — localStorage is fine. Do not store tokens in localStorage.
HTTP Method Badges
- GET — Read only
- POST — Create / action
- DELETE — Remove
Endpoints
1. Check Phone
Purpose: Entry point for every auth flow. Checks if a phone number is registered and returns a checkToken plus available auth methods.
Endpoint: POST {base_url}/auth/check
Access Level: 🌐 Public
Authentication: None
Request:
{
"identifier": "+255745051250",
"deviceId": "android-uuid-abc123"
}
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
identifier |
string | Yes | Phone number in international format | Must match ^\+[1-9]\d{6,14}$ |
deviceId |
string | Yes | Unique device identifier from the client | Non-empty |
Response — New User:
{
"success": true,
"httpStatus": "OK",
"message": "Phone number not registered",
"action": "REGISTER",
"action_time": "2026-04-03T10:30:45",
"data": {
"exists": false,
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"primaryComplete": false,
"maskedPhone": null,
"authMethods": null
}
}
Response — Existing User:
{
"success": true,
"httpStatus": "OK",
"message": "Welcome back",
"action": "LOGIN",
"action_time": "2026-04-03T10:30:45",
"data": {
"exists": true,
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"primaryComplete": true,
"maskedPhone": "••• ••• ••50",
"authMethods": {
"passwordless": true,
"password": false,
"google": true,
"apple": false
}
}
}
Response — Existing User, Primary Incomplete:
{
"success": true,
"httpStatus": "OK",
"message": "Continue setting up your account",
"action": "CONTINUE_ONBOARDING",
"action_time": "2026-04-03T10:30:45",
"data": {
"exists": true,
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"primaryComplete": false,
"maskedPhone": "••• ••• ••50",
"authMethods": {
"passwordless": true,
"password": false,
"google": false,
"apple": false
}
}
}
Response Fields:
| Field | Description |
|---|---|
exists |
Whether the phone is registered |
checkToken |
Short-lived token to proceed. Always present. |
primaryComplete |
Whether the user has completed name + birthdate setup |
maskedPhone |
Masked phone for display. Null for new users. |
authMethods.passwordless |
Always true |
authMethods.password |
True if user has set a password |
authMethods.google |
True if Google is linked |
authMethods.apple |
True if Apple is linked |
Frontend handling:
action = REGISTER
→ store checkToken in memory
→ do NOT show password field
→ do NOT show Google/Apple buttons
→ proceed to channel picker
action = LOGIN
→ store checkToken in memory
→ show Google button ONLY if authMethods.google = true
→ show Password button ONLY if authMethods.password = true
→ always show OTP button
→ proceed to channel picker
action = CONTINUE_ONBOARDING
→ same as LOGIN
→ user will be redirected to primary onboarding after OTP verify
Errors:
422 UNPROCESSABLE_ENTITY— invalid phone format or missing deviceId
2. Get Passwordless Channels
Purpose: Returns the available OTP delivery channels for the user. Always returns SMS and WHATSAPP. EMAIL is returned only if the user has a verified email.
Endpoint: POST {base_url}/auth/passwordless/channels
Access Level: 🌐 Public
Authentication: None
This endpoint does NOT consume the checkToken.
Request:
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"deviceId": "android-uuid-abc123"
}
Request Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
checkToken |
string | Yes | Token from /auth/check |
deviceId |
string | Yes | Must match the deviceId used in /auth/check |
Response — Phone only:
{
"success": true,
"httpStatus": "OK",
"message": "Choose where to receive your code",
"action": "SELECT_CHANNEL",
"action_time": "2026-04-16T10:30:45",
"data": {
"channels": [
{ "channel": "SMS", "masked": "••• ••• ••50", "isPrimary": true },
{ "channel": "WHATSAPP", "masked": "••• ••• ••50", "isPrimary": false }
]
}
}
Response — Phone + verified email:
{
"success": true,
"httpStatus": "OK",
"message": "Choose where to receive your code",
"action": "SELECT_CHANNEL",
"action_time": "2026-04-16T10:30:45",
"data": {
"channels": [
{ "channel": "SMS", "masked": "••• ••• ••50", "isPrimary": true },
{ "channel": "WHATSAPP", "masked": "••• ••• ••50", "isPrimary": false },
{ "channel": "EMAIL", "masked": "j••••••@g••••.com", "isPrimary": false }
]
}
}
This endpoint returns individual primitive channels only (
SMS,SMS_AND_WHATSAPPcompound value is not returned here — the frontend constructs it when the user wants both.
Frontend handling:
Always show at least SMS and WHATSAPP.
If EMAIL is present, show it too.
Suggested UI:
SMS → "Text message to ••• ••• ••50"
WHATSAPP → "WhatsApp to ••• ••• ••50"
EMAIL → "Email to j••••••@g••••.com"
You can also show a "Send to both SMS and WhatsApp" option —
send SMS_AND_WHATSAPP as the channel value in /auth/passwordless-start.
User taps their choice, then call /auth/passwordless-start with that channel value.
Errors:
403 FORBIDDEN— invalid, expired, or already-used checkToken403 FORBIDDEN— deviceId mismatch
3. Start Passwordless OTP
Purpose: Sends an OTP to the chosen channel and returns a tempToken for the verify step. Consumes the checkToken.
Endpoint: POST {base_url}/auth/passwordless-start
Access Level: 🌐 Public
Authentication: None
Request — SMS only:
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"channel": "SMS",
"deviceId": "android-uuid-abc123"
}
Request — WhatsApp only:
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"channel": "WHATSAPP",
"deviceId": "android-uuid-abc123"
}
Request — Both SMS and WhatsApp simultaneously:
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"channel": "SMS_AND_WHATSAPP",
"deviceId": "android-uuid-abc123"
}
Request — Email (login only, verified email required):
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"channel": "EMAIL",
"deviceId": "android-uuid-abc123"
}
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
checkToken |
string | Yes | Token from /auth/check |
Non-empty |
channel |
enum | Yes | Where to send OTP | SMS, WHATSAPP, SMS_AND_WHATSAPP, EMAIL |
deviceId |
string | Yes | Must match deviceId from /auth/check |
Non-empty |
Channel rules:
| Channel | New user (registration) | Existing user (login) |
|---|---|---|
SMS |
✅ | ✅ |
WHATSAPP |
✅ | ✅ |
SMS_AND_WHATSAPP |
✅ | ✅ |
EMAIL |
❌ | ✅ only if verified email exists |
Response:
{
"success": true,
"httpStatus": "OK",
"message": "Verification code sent",
"action_time": "2026-04-16T10:30:45",
"data": {
"tempToken": "eyJhbGciOiJIUzI1NiJ9...",
"maskedDestination": "••• ••• ••50",
"channel": "SMS_AND_WHATSAPP",
"expiresInSeconds": 120,
"resendAvailableAfterSeconds": 60
}
}
Response Fields:
| Field | Description |
|---|---|
tempToken |
Carry this to /auth/verify-otp. Store in memory only. |
maskedDestination |
Show to the user so they know where OTP was sent |
channel |
The channel used — display appropriate message |
expiresInSeconds |
OTP valid for this many seconds |
resendAvailableAfterSeconds |
Wait this long before enabling resend |
Frontend handling:
On success:
→ store tempToken in memory
→ show OTP input screen
→ display message based on channel:
SMS → "Code sent to ••• ••• ••50 via SMS"
WHATSAPP → "Code sent to ••• ••• ••50 via WhatsApp"
SMS_AND_WHATSAPP → "Code sent to ••• ••• ••50 via SMS and WhatsApp"
EMAIL → "Code sent to j••••••@g••••.com"
→ start countdown timer using resendAvailableAfterSeconds
→ enable resend button when timer hits 0
On resend:
→ channel is locked to the original choice
→ resend always goes to the same channel(s)
→ to switch channel, go back to the channel picker and restart the flow
Errors:
403 FORBIDDEN— checkToken invalid, expired, or already consumed400 BAD_REQUEST— EMAIL chosen but account has no verified email400 BAD_REQUEST— EMAIL chosen for registration400 BAD_REQUEST— non-user-selectable channel sent (e.g. ALL_CHANNELS)422 UNPROCESSABLE_ENTITY— invalid channel value
4. Verify OTP
Purpose: Validates the OTP and returns either an accessToken (returning user, primary complete) or an onboardingToken (new or incomplete user).
Endpoint: POST {base_url}/auth/verify-otp
Access Level: 🌐 Public
Authentication: None
Request:
{
"tempToken": "eyJhbGciOiJIUzI1NiJ9...",
"otp": "482910",
"deviceName": "Josh's Pixel 4a",
"platform": "ANDROID"
}
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
tempToken |
string | Yes | Token from /auth/passwordless-start |
Non-empty |
otp |
string | Yes | 6-digit code | Exactly 6 numeric digits |
deviceName |
string | No | Human-readable device name | Optional |
platform |
string | No | Client platform | ANDROID, IOS, WEB |
Response — Primary Complete:
{
"success": true,
"httpStatus": "OK",
"message": "Welcome back",
"action": null,
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"onboardingToken": null,
"primaryComplete": true,
"onboarding": {
"primaryComplete": true,
"username": true,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
},
"user": {
"displayName": "Joshua Sakweli",
"phone": "+255745051250",
"maskedPhone": "••• ••• ••50",
"avatarUrl": null
}
}
}
Response — Primary Incomplete:
{
"success": true,
"httpStatus": "OK",
"message": "Phone verified. Let us set up your account.",
"action": "COLLECT_PRIMARY",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": null,
"refreshToken": null,
"onboardingToken": "eyJhbGciOiJIUzI1NiJ9...",
"primaryComplete": false,
"onboarding": {
"primaryComplete": false,
"username": false,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
},
"user": {
"displayName": null,
"phone": "+255745051250",
"maskedPhone": "••• ••• ••50",
"avatarUrl": null
}
}
}
Frontend handling:
primaryComplete = true:
→ store accessToken in memory
→ store refreshToken in HttpOnly cookie (web) or Keychain (mobile)
→ discard tempToken
→ navigate to home
→ check onboarding flags for secondary prompts
primaryComplete = false:
→ store onboardingToken in memory
→ discard tempToken
→ navigate to primary onboarding screen
Errors:
403 FORBIDDEN— wrong OTP403 FORBIDDEN— OTP expired403 FORBIDDEN— max attempts exceeded (3 wrong OTPs)403 FORBIDDEN— tempToken already used
5. Resend OTP
Purpose: Resends the OTP to the same channel and destination as the original send. Channel cannot be changed on resend. Rate limited to 5 attempts per session with a 60-second cooldown.
Endpoint: POST {base_url}/auth/resend-otp
Access Level: 🌐 Public
Authentication: None
Request:
{
"tempToken": "eyJhbGciOiJIUzI1NiJ9..."
}
Response:
{
"success": true,
"httpStatus": "OK",
"message": "OTP resent successfully",
"action_time": "2026-04-03T10:30:45",
"data": {
"tempToken": "eyJhbGciOiJIUzI1NiJ9...",
"maskedIdentifier": "••• ••• ••50",
"remainingAttempts": 4,
"expiresIn": 900
}
}
Frontend handling:
On success:
→ replace tempToken in memory with the new one from response
→ show "Code resent" confirmation
→ reset the countdown timer to resendAvailableAfterSeconds
→ disable resend button again
On 400 — cooldown active:
→ show "Please wait X seconds"
→ do not clear the OTP input
On 400 — max attempts:
→ show "Too many attempts. Please start over."
→ clear tempToken from memory
→ navigate back to channel picker
Channel switching:
→ NOT possible via resend
→ user must go back to channel picker and call /auth/passwordless-start again
Errors:
400 BAD_REQUEST— cooldown period not yet elapsed400 BAD_REQUEST— max resend attempts (5) reached400 BAD_REQUEST— tempToken expired or invalid
6. Primary Onboarding
Purpose: Collects name and date of birth. Completes primary onboarding and issues the first accessToken.
Endpoint: POST {base_url}/auth/onboarding/primary
Access Level: 🌐 Public
Authentication: None (uses onboardingToken in body)
Request:
{
"onboardingToken": "eyJhbGciOiJIUzI1NiJ9...",
"firstName": "Joshua",
"lastName": "Sakweli",
"birthDate": "1995-06-15"
}
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
onboardingToken |
string | Yes | Token from /auth/verify-otp |
Non-empty |
firstName |
string | Yes | User's first name | 1–50 characters |
lastName |
string | Yes | User's last name | 1–50 characters |
birthDate |
string | Yes | Date of birth | YYYY-MM-DD, must be in the past |
Response — Normal User:
{
"success": true,
"httpStatus": "OK",
"message": "Welcome to NextGate!",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"accountTier": "FULL",
"onboarding": {
"primaryComplete": true,
"username": false,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
},
"blocked": false,
"unblockDate": null,
"user": {
"displayName": "Joshua Sakweli",
"phone": "+255745051250",
"maskedPhone": "••• ••• ••50",
"avatarUrl": null
}
}
}
Response — Underage User:
{
"success": true,
"httpStatus": "OK",
"message": "Account blocked",
"action": "ACCOUNT_BLOCKED",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": null,
"refreshToken": null,
"accountTier": null,
"onboarding": null,
"blocked": true,
"unblockDate": "2026-09-15"
}
}
Response Fields:
| Field | Description |
|---|---|
accessToken |
Bearer token. Store in memory. Null if blocked. |
refreshToken |
Rotation token. Store securely. Null if blocked. |
accountTier |
FULL (18+), RESTRICTED (13–17), MINOR (under 13 — blocked) |
blocked |
True if user is underage |
unblockDate |
Date when user turns 13. Show to user. |
Frontend handling:
blocked = false:
→ store accessToken in memory
→ store refreshToken securely
→ discard onboardingToken
→ navigate to home
→ check onboarding flags for secondary prompts
blocked = true:
→ do NOT store any tokens
→ show age restriction screen with unblockDate
→ do NOT allow navigation into the app
accountTier = RESTRICTED:
→ user is 13–17
→ restrict features per your tier config
Errors:
403 FORBIDDEN— onboardingToken invalid or expired403 FORBIDDEN— primary onboarding already completed422 UNPROCESSABLE_ENTITY— validation errors on name or birthDate
7. Password Login
Purpose: Authenticates a user with phone + password. May require device verification if the device is unknown or risk is high.
Endpoint: POST {base_url}/auth/login/password
Access Level: 🌐 Public
Authentication: None
Request:
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"password": "MySecurePassword123",
"deviceId": "android-uuid-abc123",
"deviceName": "Josh's Pixel 4a",
"platform": "ANDROID"
}
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
checkToken |
string | Yes | Token from /auth/check |
Non-empty |
password |
string | Yes | User's password | Non-empty |
deviceId |
string | Yes | Device identifier | Non-empty |
deviceName |
string | No | Human-readable device name | Optional |
platform |
string | No | Client platform | ANDROID, IOS, WEB |
Response — Known Device:
{
"success": true,
"httpStatus": "OK",
"message": "Login successful",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": false,
"profilePic": false,
"interests": true,
"bio": false
},
"requiresDeviceVerification": false,
"deviceVerificationToken": null,
"maskedDestination": null
}
}
Response — Unknown / High Risk Device:
{
"success": true,
"httpStatus": "OK",
"message": "Device verification required",
"action": "VERIFY_DEVICE",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": null,
"refreshToken": null,
"requiresDeviceVerification": true,
"deviceVerificationToken": "eyJhbGciOiJIUzI1NiJ9...",
"maskedDestination": "••• ••• ••50"
}
}
Frontend handling:
requiresDeviceVerification = false:
→ store accessToken in memory
→ store refreshToken securely
→ navigate to home
requiresDeviceVerification = true:
→ store deviceVerificationToken in memory
→ show OTP input with maskedDestination
→ call POST /api/v1/account/device/verify with the OTP
→ on success you get accessToken + refreshToken
Errors:
403 FORBIDDEN— wrong password403 FORBIDDEN— checkToken invalid or expired403 FORBIDDEN— too many failed attempts403 FORBIDDEN— password not set on this account
8. OAuth Login
Purpose: Authenticates a user via Google or Apple. Only available if the provider was previously linked to the account.
Endpoint: POST {base_url}/auth/login/oauth
Access Level: 🌐 Public
Authentication: None
Request:
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"provider": "GOOGLE",
"idToken": "google-id-token-from-client-sdk",
"deviceId": "android-uuid-abc123",
"deviceName": "Josh's Pixel 4a",
"platform": "ANDROID",
"state": "optional-state-string"
}
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
checkToken |
string | Yes | Token from /auth/check |
Non-empty |
provider |
string | Yes | OAuth provider | GOOGLE, APPLE |
idToken |
string | Yes | ID token from Google/Apple client SDK | Non-empty |
deviceId |
string | Yes | Device identifier | Non-empty |
deviceName |
string | No | Human-readable device name | Optional |
platform |
string | No | Client platform | ANDROID, IOS, WEB |
state |
string | No | Opaque state value passed back in response | Optional |
Response:
{
"success": true,
"httpStatus": "OK",
"message": "Login successful",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": true,
"profilePic": false,
"interests": true,
"bio": false
},
"state": "optional-state-string"
}
}
Frontend handling:
Before calling this endpoint:
→ check authMethods.google from /auth/check response
→ ONLY show Google button if google = true
→ ONLY show Apple button if apple = true
On success:
→ store accessToken in memory
→ store refreshToken securely
→ navigate to home
Errors:
403 FORBIDDEN— provider not linked (OAUTH_NOT_LINKED)403 FORBIDDEN— idToken invalid or expired403 FORBIDDEN— idToken email does not match linked provider403 FORBIDDEN— checkToken invalid or expired
9. Forgot Password — Initiate
Purpose: Starts the forgot password flow. Sends an OTP to the user's phone via SMS. Does NOT consume the checkToken.
Endpoint: POST {base_url}/auth/password/forgot/initiate
Access Level: 🌐 Public
Authentication: None
Request:
{
"checkToken": "eyJhbGciOiJIUzI1NiJ9...",
"deviceId": "android-uuid-abc123"
}
Response:
{
"success": true,
"httpStatus": "OK",
"message": "Password reset code sent to your phone",
"action_time": "2026-04-03T10:30:45",
"data": {
"tempToken": "eyJhbGciOiJIUzI1NiJ9...",
"resetToken": null,
"maskedPhone": "••• ••• ••50",
"accessToken": null,
"expiresInSeconds": 120
}
}
Frontend handling:
→ store tempToken in memory
→ show OTP input screen
→ display maskedPhone
→ proceed to /auth/password/forgot/verify-otp
Errors:
403 FORBIDDEN— checkToken invalid or expired403 FORBIDDEN— account has no password set404 NOT_FOUND— account not found
10. Forgot Password — Verify OTP
Purpose: Verifies the OTP and issues a short-lived resetToken.
Endpoint: POST {base_url}/auth/password/forgot/verify-otp
Access Level: 🌐 Public
Authentication: None
Request:
{
"tempToken": "eyJhbGciOiJIUzI1NiJ9...",
"otp": "482910"
}
Response:
{
"success": true,
"httpStatus": "OK",
"message": "Identity confirmed. Set your new password.",
"action_time": "2026-04-03T10:30:45",
"data": {
"tempToken": null,
"resetToken": "eyJhbGciOiJIUzI1NiJ9...",
"maskedPhone": null,
"accessToken": null,
"expiresInSeconds": 0
}
}
Frontend handling:
→ discard tempToken from memory
→ store resetToken in memory
→ navigate to new password input screen
Errors:
403 FORBIDDEN— wrong OTP403 FORBIDDEN— OTP expired403 FORBIDDEN— max OTP attempts exceeded
11. Forgot Password — Reset
Purpose: Sets the new password. Revokes all existing sessions and issues a fresh accessToken.
Endpoint: POST {base_url}/auth/password/forgot/reset
Access Level: 🌐 Public
Authentication: None
Request:
{
"resetToken": "eyJhbGciOiJIUzI1NiJ9...",
"newPassword": "MyNewSecurePassword456",
"confirmPassword": "MyNewSecurePassword456"
}
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
resetToken |
string | Yes | Token from verify OTP step | Non-empty |
newPassword |
string | Yes | New password | Min 8 characters |
confirmPassword |
string | Yes | Must match newPassword | Non-empty |
Response:
{
"success": true,
"httpStatus": "OK",
"message": "Password updated. All other sessions signed out.",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"resetToken": null,
"maskedPhone": null,
"expiresInSeconds": 0
}
}
Frontend handling:
→ discard resetToken from memory
→ store accessToken in memory
→ clear any existing refreshToken from storage
→ navigate to home
→ show "Password updated successfully"
Errors:
400 BAD_REQUEST— passwords do not match403 FORBIDDEN— resetToken invalid or expired422 UNPROCESSABLE_ENTITY— password too short
12. Refresh Token
Purpose: Exchanges a refresh token for a new access + refresh token pair. Old refresh token is invalidated immediately (rotation).
Endpoint: POST {base_url}/auth/token/refresh
Access Level: 🌐 Public
Authentication: None
Request:
{
"refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}
Response:
{
"success": true,
"httpStatus": "OK",
"message": "Token refreshed",
"action_time": "2026-04-03T10:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"expiresIn": 3600
}
}
Frontend handling:
Call this when:
→ accessToken is expired (401 on a protected request)
→ proactively before expiry (check exp claim in JWT)
On success:
→ replace accessToken in memory
→ replace refreshToken in secure storage
→ retry the original failed request
On 401:
→ clear all tokens
→ redirect to login
Errors:
13. Revoke Token
Purpose: Logs out the user by revoking their refresh token.
Endpoint: POST {base_url}/auth/token/revoke
Access Level: 🌐 Public
Authentication: None
Request:
{
"refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}
Response:
{
"success": true,
"httpStatus": "OK",
"message": "Token revoked successfully",
"action_time": "2026-04-03T10:30:45",
"data": null
}
Frontend handling:
On logout:
→ call this endpoint with the stored refreshToken
→ clear accessToken from memory
→ clear refreshToken from secure storage
→ redirect to login
If call fails (network error):
→ still clear tokens locally
→ user is effectively logged out on the client
Quick Reference — Full Auth Flow
1. POST /auth/check
→ phone + deviceId → checkToken + action
2. POST /auth/passwordless/channels (does not consume checkToken)
→ returns available channels: SMS, WHATSAPP, and optionally EMAIL
3. POST /auth/passwordless-start (consumes checkToken)
→ channel (SMS | WHATSAPP | SMS_AND_WHATSAPP | EMAIL) → tempToken + OTP sent
4. POST /auth/verify-otp (consumes tempToken)
→ otp → accessToken (returning user) or onboardingToken (new user)
5. POST /auth/onboarding/primary (if onboardingToken received)
→ name + birthDate → accessToken issued
─── User is now logged in ───
6. Secondary onboarding (optional, progressive)
→ username, email, interests, bio, profile pic
→ each step returns new accessToken with updated onboarding flags
─── Token management ───
7. POST /auth/token/refresh → rotate tokens silently
8. POST /auth/token/revoke → logout
Error Handling Summary
| HTTP Status | When it happens | What to do |
|---|---|---|
400 BAD_REQUEST |
Invalid input, item exists, rate limit | Show error message to user |
401 UNAUTHORIZED |
Token expired or invalid | Refresh token or redirect to login |
403 FORBIDDEN |
Wrong OTP, wrong password, token mismatch | Show specific error, let user retry |
404 NOT_FOUND |
Account not found | Show "Account not found" |
422 UNPROCESSABLE_ENTITY |
Validation failed | Show field-level errors |
500 INTERNAL_SERVER_ERROR |
Server error | Show generic error, retry |
PONA Auth Secondary Onboarding
Base URL: https://api.nexgate.co/api/v1/onboarding/secondary
Short Description: Secondary onboarding completes a user's profile after the primary onboarding step (name, phone, birth date). It is a step-machine — each endpoint returns the next missing step and a refreshed access token. The flow is complete when stepsRemaining reaches 0 and nextMissing is null.
Hints:
- All endpoints require a valid
Beareraccess token issued after primary onboarding or login. - Every response includes a fresh
accessTokenwith updated onboarding flags embedded in the JWT claims — replace the stored token after each step. - Steps can be completed in any order. The
nextMissingfield signals the next recommended step; it does not enforce ordering. - Email linking has two independent paths: custom (user-provided email + OTP) and Google (Google ID token). Only one path needs to be completed.
- Profile picture upload expects
multipart/form-data, not JSON.
Standard Response Format
All API responses follow a consistent structure using our Globe Response Builder pattern:
Success Response Structure
{
"success": true,
"httpStatus": "OK",
"message": "Operation completed successfully",
"action_time": "2025-09-23T10:30:45",
"action": "COLLECT_EMAIL",
"data": {}
}
Error Response Structure
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Error description",
"action_time": "2025-09-23T10:30:45",
"data": "Error description"
}
Standard Response Fields
| Field | Type | Description |
|---|---|---|
success |
boolean | true for successful operations, false for errors |
httpStatus |
string | HTTP status name (OK, BAD_REQUEST, etc.) |
message |
string | Human-readable result description |
action_time |
string | ISO 8601 timestamp of the response |
action |
string | Next frontend action to perform (see action codes below) |
data |
object/string | Response payload or error detail |
Action Codes
| Code | Meaning |
|---|---|
COLLECT_USERNAME |
Username step is next |
COLLECT_EMAIL |
Email step is next |
COLLECT_PROFILE_PIC |
Profile picture step is next |
COLLECT_INTERESTS |
Interests step is next |
COLLECT_BIO |
Bio step is next |
PROCEED |
All steps complete — onboarding is done |
Standard Secondary Onboarding Response Fields
Most endpoints return a SecondaryOnboardingResponse. Its fields are:
| Field | Type | Description |
|---|---|---|
accessToken |
string | Fresh JWT — store and use this for all subsequent requests |
onboarding |
object | Current completion state of all onboarding steps (see below) |
nextMissing |
string | Key name of the next incomplete step, or null if all done |
stepsRemaining |
integer | Number of steps still pending |
onboarding Object Fields
| Field | Type | Description |
|---|---|---|
primaryComplete |
boolean | Primary onboarding (name/phone/DOB) is done |
username |
boolean | Username has been set |
email |
boolean | Email has been linked and verified |
profilePic |
boolean | Profile picture has been uploaded |
interests |
boolean | Interests have been selected |
bio |
boolean | Bio has been written |
Endpoints
1. Get Username Suggestions
Purpose: Returns up to 5 AI-generated username suggestions based on the user's first name, last name, and birth date.
Endpoint: GET {base_url}/username/suggestions
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Username suggestions",
"action_time": "2026-04-27T10:30:45",
"data": {
"suggestions": [
"john_sakweli",
"johnsakweli99",
"j_sakweli",
"johnsak2004",
"jsakweli_"
]
}
}
Success Response Fields:
| Field | Description |
|---|---|
suggestions |
Array of up to 5 available username strings |
Error Response JSON Sample:
{
"success": false,
"httpStatus": "NOT_FOUND",
"message": "Account not found",
"action_time": "2026-04-27T10:30:45",
"data": "Account not found"
}
2. Set Username
Purpose: Sets a unique username for the account.
Endpoint: POST {base_url}/username
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Content-Type |
string | Yes | application/json |
Request JSON Sample:
{
"username": "john_sakweli"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
username |
string | Yes | Desired username | Min: 3, Max: 30 characters. Must start with a letter. Only letters, numbers, and underscores allowed. |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Username set successfully",
"action_time": "2026-04-27T10:30:45",
"action": "COLLECT_EMAIL",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": false,
"profilePic": false,
"interests": false,
"bio": false
},
"nextMissing": "email",
"stepsRemaining": 4
}
}
Success Response Fields:
| Field | Description |
|---|---|
accessToken |
Fresh JWT with updated onboarding claims — replace stored token |
onboarding.username |
Now true |
nextMissing |
Next recommended step key |
stepsRemaining |
Steps left to complete |
Error Response JSON Sample:
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Username is already taken",
"action_time": "2026-04-27T10:30:45",
"data": "Username is already taken"
}
Standard Error Types:
400 BAD_REQUEST: Username already taken or invalid format401 UNAUTHORIZED: Missing or invalid token422 UNPROCESSABLE_ENTITY: Validation failure (length, pattern)
3. Set Bio
Purpose: Saves a short bio to the user's profile.
Endpoint: POST {base_url}/bio
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Content-Type |
string | Yes | application/json |
Request JSON Sample:
{
"bio": "Event enthusiast, live music lover, always at the front row."
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
bio |
string | Yes | User's short profile bio | Max: 160 characters. Cannot be blank. |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Bio saved",
"action_time": "2026-04-27T10:30:45",
"action": "PROCEED",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": true,
"profilePic": true,
"interests": true,
"bio": true
},
"nextMissing": null,
"stepsRemaining": 0
}
}
Standard Error Types:
400 BAD_REQUEST: Bio is blank401 UNAUTHORIZED: Missing or invalid token422 UNPROCESSABLE_ENTITY: Exceeds 160 characters
4. Set Interests
Purpose: Saves the user's selected interest categories (minimum 3 required).
Endpoint: POST {base_url}/interests
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Content-Type |
string | Yes | application/json |
Request JSON Sample:
{
"interestIds": [
"3fa85f64-5717-4562-b3fc-2c963f66afa6",
"7a1234bc-1234-4321-abcd-1234567890ab",
"9c87654d-4321-1234-dcba-0987654321cd"
]
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
interestIds |
array of UUID | Yes | IDs of selected interest categories | Min: 3 items. Must not be empty. Use the interests listing endpoint to get valid IDs. |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Interests saved",
"action_time": "2026-04-27T10:30:45",
"action": "COLLECT_BIO",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": true,
"profilePic": true,
"interests": true,
"bio": false
},
"nextMissing": "bio",
"stepsRemaining": 1
}
}
Standard Error Types:
400 BAD_REQUEST: Interest IDs not found401 UNAUTHORIZED: Missing or invalid token422 UNPROCESSABLE_ENTITY: Fewer than 3 interests selected
5. Upload Profile Picture
Purpose: Uploads and stores the user's profile picture.
Endpoint: POST {base_url}/profile-pic
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Content-Type |
string | Yes | multipart/form-data |
Query Parameters:
| Parameter | Type | Required | Description | Validation | Default |
|---|---|---|---|---|---|
file |
file (form field) | Yes | Image file to upload | Image formats: JPEG, PNG, WEBP | — |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Profile picture uploaded",
"action_time": "2026-04-27T10:30:45",
"action": "COLLECT_INTERESTS",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": true,
"profilePic": true,
"interests": false,
"bio": false
},
"nextMissing": "interests",
"stepsRemaining": 2
}
}
Standard Error Types:
400 BAD_REQUEST: File is missing, empty, or unsupported format401 UNAUTHORIZED: Missing or invalid token500 INTERNAL_SERVER_ERROR: Storage failure
6. Initiate Email Linking (Custom)
Purpose: Sends a 6-digit OTP to the provided email address and returns a tempToken required for the verify step.
Endpoint: POST {base_url}/email/custom/initiate
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Content-Type |
string | Yes | application/json |
Request JSON Sample:
{
"email": "john@example.com"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
email |
string | Yes | Email address to link | Must be a valid email format. Cannot be blank. |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Verification code sent to your email",
"action_time": "2026-04-27T10:30:45",
"data": {
"tempToken": "eyJhbGciOiJSUzI1NiJ9.temp...",
"nextAction": "VERIFY_EMAIL"
}
}
Success Response Fields:
| Field | Description |
|---|---|
tempToken |
Short-lived token required as input to the verify step. Store this until OTP is submitted. |
nextAction |
Always VERIFY_EMAIL — signals frontend to show OTP input screen |
Standard Error Types:
400 BAD_REQUEST: Email already in use by another account401 UNAUTHORIZED: Missing or invalid token422 UNPROCESSABLE_ENTITY: Invalid email format
7. Verify Email (Custom)
Purpose: Confirms the OTP sent to the user's email and marks the email step as complete.
Endpoint: POST {base_url}/email/custom/verify
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Content-Type |
string | Yes | application/json |
Request JSON Sample:
{
"tempToken": "eyJhbGciOiJSUzI1NiJ9.temp...",
"otp": "847291"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
tempToken |
string | Yes | Token received from the initiate step | Cannot be blank |
otp |
string | Yes | 6-digit code sent to the user's email | Exactly 6 digits, numeric only |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Email verified",
"action_time": "2026-04-27T10:30:45",
"action": "COLLECT_PROFILE_PIC",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": true,
"profilePic": false,
"interests": false,
"bio": false
},
"nextMissing": "profilePic",
"stepsRemaining": 3
}
}
Standard Error Types:
400 BAD_REQUEST: OTP is incorrect or expired401 UNAUTHORIZED:tempTokenis invalid or expired422 UNPROCESSABLE_ENTITY: OTP is not 6 numeric digits
8. Link Email via Google
Purpose: Links and verifies a Google-backed email using a Google ID token — skips the OTP step entirely.
Endpoint: POST {base_url}/email/google
Access Level: 🔒 Protected (Authenticated user)
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization |
string | Yes | Bearer <access_token> |
Content-Type |
string | Yes | application/json |
Request JSON Sample:
{
"idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6..."
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
idToken |
string | Yes | Google ID token from the Google Sign-In SDK | Cannot be blank. Must be a valid Google-issued token. |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Email linked via Google",
"action_time": "2026-04-27T10:30:45",
"action": "COLLECT_PROFILE_PIC",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiJ9...",
"onboarding": {
"primaryComplete": true,
"username": true,
"email": true,
"profilePic": false,
"interests": false,
"bio": false
},
"nextMissing": "profilePic",
"stepsRemaining": 3
}
}
Standard Error Types:
400 BAD_REQUEST: Google token is invalid, expired, or email already linked to another account401 UNAUTHORIZED: Missing or invalid Bearer token422 UNPROCESSABLE_ENTITY:idTokenis blank
Flow Overview
Primary Onboarding Complete
│
▼
┌───────────────┐
│ Set Username │ POST /username
└───────┬───────┘
│
▼
┌───────────────┐ ┌──────────────────────┐
│ Link Email │─────────│ Option A: Custom │ POST /email/custom/initiate
└───────┬───────┘ │ → POST /email/custom/verify
│ ├──────────────────────┤
│ │ Option B: Google │ POST /email/google
│ └──────────────────────┘
▼
┌───────────────┐
│ Profile Pic │ POST /profile-pic
└───────┬───────┘
│
▼
┌───────────────┐
│ Interests │ POST /interests (min. 3)
└───────┬───────┘
│
▼
┌───────────────┐
│ Bio │ POST /bio
└───────┬───────┘
│
▼
action: PROCEED
(onboarding done)
Steps can be completed out of order. The
nextMissingfield in each response always signals the next recommended incomplete step.
Quick Reference
| # | Endpoint | Method | Auth | Purpose |
|---|---|---|---|---|
| 1 | /username/suggestions |
GET | Bearer | Get suggested usernames |
| 2 | /username |
POST | Bearer | Set username |
| 3 | /bio |
POST | Bearer | Set bio |
| 4 | /interests |
POST | Bearer | Set interests |
| 5 | /profile-pic |
POST | Bearer | Upload profile picture |
| 6 | /email/custom/initiate |
POST | Bearer | Send OTP to email |
| 7 | /email/custom/verify |
POST | Bearer | Verify email with OTP |
| 8 | /email/google |
POST | Bearer | Link email via Google token |