PONA AUTH V3

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


pona_auth_flow_diagram.jpg

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

Being Added

Staying Exactly as They Are


Security Notes

NextGate PONA Auth — EndPoint Doc (ACTIVE)

Author: Josh S. Sakweli, Backend Lead — QBIT SPARK CO LIMITED
Last Updated: 2026-04-19
Version: v1.2
Base URL: https://your-api-domain.com/api/v1

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:

pona_auth_flow_diagram.jpg

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_WHATSAPP fires 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_CHANNELS are 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, and avatarUrl are display data — localStorage is fine. Do not store tokens in localStorage.


HTTP Method Badges


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:


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, WHATSAPP, EMAIL). The SMS_AND_WHATSAPP compound 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:


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:


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:


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:


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:


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:


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:


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:


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:


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:


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

Author: Josh S. Sakweli, Backend Lead Team
Last Updated: 2026-04-27
Version: v1.0

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:


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:


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:


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:


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:


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:


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:


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:


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 nextMissing field 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