# 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

- **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

---

[![pona_auth_flow_diagram.jpg](https://doc-hub.qbitspark.com/uploads/images/gallery/2026-04/scaled-1680-/pona-auth-flow-diagram.jpg)](https://doc-hub.qbitspark.com/uploads/images/gallery/2026-04/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

```json
{
  "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

```json
{
  "success": true,
  "message": "Human readable message",
  "action": "NEXT_ACTION_OR_NULL",
  "data": { }
}
```

### Error — HTTP 422

```json
{
  "success": false,
  "message": "Human readable message",
  "action": "NEXT_ACTION_CODE",
  "context": "what_user_was_trying_to_do",
  "data": { }
}
```

---

## Response Examples

### /auth/check — New User
```json
{
  "success": true,
  "message": "Phone number not registered",
  "action": "REGISTER",
  "data": { "exists": false, "checkToken": null }
}
```

### /auth/check — Existing User Ready
```json
{
  "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
```json
{
  "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
```json
{
  "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
```json
{
  "success": true,
  "message": "Sending code to your phone",
  "action": "PROCEED_TO_OTP",
  "data": {
    "channels": [
      { "type": "PHONE", "masked": "••• ••• ••78", "isPrimary": true }
    ]
  }
}
```

### /auth/start — OTP Sent
```json
{
  "success": true,
  "message": "Verification code sent",
  "action": null,
  "data": {
    "tempToken": "eyJ...",
    "maskedDestination": "••• ••• ••78",
    "channel": "PHONE",
    "expiresInSeconds": 120,
    "resendAvailableAfterSeconds": 60
  }
}
```

### /auth/verify — Primary Incomplete
```json
{
  "success": true,
  "message": "Phone verified. Let us set up your account.",
  "action": "COLLECT_PRIMARY",
  "data": {
    "onboardingToken": "eyJ...",
    "nextStep": "name"
  }
}
```

### /auth/verify — Primary Already Complete
```json
{
  "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
```json
{
  "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
```json
{
  "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
```json
{
  "success": false,
  "message": "Incorrect OTP code",
  "action": "RETRY_OTP",
  "context": "otp_verify",
  "data": { "attemptsRemaining": 2 }
}
```

### OTP Expired
```json
{
  "success": false,
  "message": "OTP has expired",
  "action": "RESEND_OTP",
  "context": "otp_expired",
  "data": { "resendAvailable": true, "resendCooldownSeconds": 0 }
}
```

### Rate Limited
```json
{
  "success": false,
  "message": "Too many attempts. Please wait.",
  "action": "WAIT",
  "context": "rate_limited",
  "data": { "retryAfterSeconds": 120 }
}
```

### Wrong Auth Method
```json
{
  "success": false,
  "message": "This account does not use password login",
  "action": "USE_OTP",
  "context": "password_login",
  "data": { "availableMethods": ["passwordless", "google"] }
}
```

### Secondary Gate — Multiple Missing
```json
{
  "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
```json
{
  "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
```json
{
  "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
```json
{
  "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

```json
{
  "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 `OnboardingStep` tracking → replaced by independent flags
- `onboardingStep` database column → database migration required
- `isOnboardingComplete()` → replaced by `isPrimaryComplete()`
- Onboarding token for secondary steps → access token handles all of that now
- `refreshOnboardingToken` endpoint → no longer needed
- Email and username as login identifiers → phone only from now on
- Raw identifier passed to `/auth/start` → replaced by `checkToken` + `channel`

### Being Added
- `POST /auth/check` — new entry point
- `POST /auth/passwordless/channels` — new channel check endpoint
- `OnboardingFlagResolver` — derives all flags from existing account data
- Resource guard — checks flags, returns next action automatically
- `checkToken` generation in JWT system
- `channel` field on `/auth/start`
- All secondary onboarding endpoints
- `action` and `context` on 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/check` rate limited — max 10 per IP per minute, max 3 per phone per hour
- `checkToken` single-use — consumed the moment any auth action is taken
- `checkToken` cryptographically 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)

**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](https://doc-hub.qbitspark.com/books/authentication-nexgate-service1/page/nextgate-pona-auth-flow-v3-progressive-onboarding-native-access#bkmrk-%2Fauth%2Fcheck-%E2%80%94-existi)

---

## What is PONA Auth?

**P**rogressive · **O**nboarding · **N**ative · **A**ccess

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.

[![pona_auth_flow_diagram.jpg](https://doc-hub.qbitspark.com/uploads/images/gallery/2026-04/scaled-1680-/pona-auth-flow-diagram.jpg)](https://doc-hub.qbitspark.com/uploads/images/gallery/2026-04/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
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Human-readable message",
  "action": "ACTION_CODE",
  "action_time": "2026-04-03T10:30:45",
  "data": {}
}
```

### Error response
```json
{
  "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:
```json
{ "channel": "SMS" }
```

Send via WhatsApp only:
```json
{ "channel": "WHATSAPP" }
```

Send via both SMS and WhatsApp at the same time:
```json
{ "channel": "SMS_AND_WHATSAPP" }
```

Send via email (login only, verified email required):
```json
{ "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.).

```json
{
  "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

- <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> — Read only
- <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> — Create / action
- <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> — 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/check`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "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**:
```json
{
  "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**:
```json
{
  "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**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/passwordless/channels`

**Access Level**: 🌐 Public

**Authentication**: None

> This endpoint does NOT consume the checkToken.

**Request**:
```json
{
  "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**:
```json
{
  "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**:
```json
{
  "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**:
- `403 FORBIDDEN` — invalid, expired, or already-used checkToken
- `403 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/passwordless-start`

**Access Level**: 🌐 Public

**Authentication**: None

**Request — SMS only**:
```json
{
  "checkToken": "eyJhbGciOiJIUzI1NiJ9...",
  "channel": "SMS",
  "deviceId": "android-uuid-abc123"
}
```

**Request — WhatsApp only**:
```json
{
  "checkToken": "eyJhbGciOiJIUzI1NiJ9...",
  "channel": "WHATSAPP",
  "deviceId": "android-uuid-abc123"
}
```

**Request — Both SMS and WhatsApp simultaneously**:
```json
{
  "checkToken": "eyJhbGciOiJIUzI1NiJ9...",
  "channel": "SMS_AND_WHATSAPP",
  "deviceId": "android-uuid-abc123"
}
```

**Request — Email (login only, verified email required)**:
```json
{
  "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**:
```json
{
  "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 consumed
- `400 BAD_REQUEST` — EMAIL chosen but account has no verified email
- `400 BAD_REQUEST` — EMAIL chosen for registration
- `400 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/verify-otp`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "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**:
```json
{
  "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**:
```json
{
  "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 OTP
- `403 FORBIDDEN` — OTP expired
- `403 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/resend-otp`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "tempToken": "eyJhbGciOiJIUzI1NiJ9..."
}
```

**Response**:
```json
{
  "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 elapsed
- `400 BAD_REQUEST` — max resend attempts (5) reached
- `400 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/onboarding/primary`

**Access Level**: 🌐 Public

**Authentication**: None (uses `onboardingToken` in body)

**Request**:
```json
{
  "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**:
```json
{
  "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**:
```json
{
  "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 expired
- `403 FORBIDDEN` — primary onboarding already completed
- `422 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/login/password`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "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**:
```json
{
  "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**:
```json
{
  "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 password
- `403 FORBIDDEN` — checkToken invalid or expired
- `403 FORBIDDEN` — too many failed attempts
- `403 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/login/oauth`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "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**:
```json
{
  "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 expired
- `403 FORBIDDEN` — idToken email does not match linked provider
- `403 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/password/forgot/initiate`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "checkToken": "eyJhbGciOiJIUzI1NiJ9...",
  "deviceId": "android-uuid-abc123"
}
```

**Response**:
```json
{
  "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 expired
- `403 FORBIDDEN` — account has no password set
- `404 NOT_FOUND` — account not found

---

## 10. Forgot Password — Verify OTP

**Purpose**: Verifies the OTP and issues a short-lived `resetToken`.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/password/forgot/verify-otp`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "tempToken": "eyJhbGciOiJIUzI1NiJ9...",
  "otp": "482910"
}
```

**Response**:
```json
{
  "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 OTP
- `403 FORBIDDEN` — OTP expired
- `403 FORBIDDEN` — max OTP attempts exceeded

---

## 11. Forgot Password — Reset

**Purpose**: Sets the new password. Revokes all existing sessions and issues a fresh `accessToken`.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/password/forgot/reset`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "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**:
```json
{
  "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 match
- `403 FORBIDDEN` — resetToken invalid or expired
- `422 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/token/refresh`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}
```

**Response**:
```json
{
  "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**:
- `401 UNAUTHORIZED` — refresh token invalid, expired, or revoked
- `401 UNAUTHORIZED` — token reuse detected — session revoked

---

## 13. Revoke Token

**Purpose**: Logs out the user by revoking their refresh token.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/auth/token/revoke`

**Access Level**: 🌐 Public

**Authentication**: None

**Request**:
```json
{
  "refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}
```

**Response**:
```json
{
  "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**:
- All endpoints require a valid `Bearer` access token issued after primary onboarding or login.
- Every response includes a fresh `accessToken` with updated onboarding flags embedded in the JWT claims — replace the stored token after each step.
- Steps can be completed in any order. The `nextMissing` field 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
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Operation completed successfully",
  "action_time": "2025-09-23T10:30:45",
  "action": "COLLECT_EMAIL",
  "data": {}
}
```

### Error Response Structure
```json
{
  "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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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**:
```json
{
  "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**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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**:
```json
{
  "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**:
```json
{
  "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 format
- `401 UNAUTHORIZED`: Missing or invalid token
- `422 UNPROCESSABLE_ENTITY`: Validation failure (length, pattern)

---

## 3. Set Bio

**Purpose**: Saves a short bio to the user's profile.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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**:
```json
{
  "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 blank
- `401 UNAUTHORIZED`: Missing or invalid token
- `422 UNPROCESSABLE_ENTITY`: Exceeds 160 characters

---

## 4. Set Interests

**Purpose**: Saves the user's selected interest categories (minimum 3 required).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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**:
```json
{
  "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 found
- `401 UNAUTHORIZED`: Missing or invalid token
- `422 UNPROCESSABLE_ENTITY`: Fewer than 3 interests selected

---

## 5. Upload Profile Picture

**Purpose**: Uploads and stores the user's profile picture.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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 format
- `401 UNAUTHORIZED`: Missing or invalid token
- `500 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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**:
```json
{
  "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 account
- `401 UNAUTHORIZED`: Missing or invalid token
- `422 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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**:
```json
{
  "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 expired
- `401 UNAUTHORIZED`: `tempToken` is invalid or expired
- `422 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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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**:
```json
{
  "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 account
- `401 UNAUTHORIZED`: Missing or invalid Bearer token
- `422 UNPROCESSABLE_ENTITY`: `idToken` is 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 `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 |