Auth, Files, Profile & Onboarding
Authentication
Base URL: https://api.fursahub.com/api/v1
Short Description: Authentication endpoints for Fursa Hub platform. Handles user registration/login via Firebase, token management, and session control. All new users start the onboarding flow after successful authentication.
Hints:
- Firebase handles the actual sign-in (Google, Apple, Email) - your app gets a Firebase ID token
- Send that Firebase token to our backend to get Fursa Hub access tokens
- Access tokens expire in 1 hour, use refresh token to get new ones
- Pass
preferredLanguageandthemeduring first authentication to set user preferences
Authentication Flow
┌─────────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. USER OPENS APP │
│ └── App shows language selector (calls GET /languages) │
│ └── User picks language (e.g., "sw" for Swahili) │
│ └── App stores language + theme preference locally │
│ │
│ 2. USER SIGNS IN VIA FIREBASE │
│ └── Google Sign-In / Apple Sign-In / Email+Password │
│ └── Firebase returns ID Token │
│ │
│ 3. APP SENDS TO FURSA HUB BACKEND │
│ └── POST /auth/firebase/authenticate │
│ └── Include: firebaseToken, preferredLanguage, theme │
│ │
│ 4. BACKEND RESPONSE │
│ ├── NEW USER: Creates account, returns tokens + onboarding status │
│ └── EXISTING USER: Returns tokens + current onboarding status │
│ │
│ 5. CHECK ONBOARDING STATUS │
│ └── onboarding.isComplete = false → Navigate to onboarding flow │
│ └── onboarding.isComplete = true → Navigate to home screen │
│ │
│ 6. TOKEN MANAGEMENT │
│ └── Store accessToken (for API calls) │
│ └── Store refreshToken (for renewing accessToken) │
│ └── When accessToken expires → POST /auth/refresh │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Standard Response Format
Success Response Structure
{
"success": true,
"httpStatus": "OK",
"message": "Operation completed successfully",
"action_time": "2025-01-05T10:30:45",
"data": { }
}
Error Response Structure
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Error description",
"action_time": "2025-01-05T10:30:45",
"data": "Error description"
}
Endpoints
1. Authenticate with Firebase
Purpose: Exchange Firebase ID token for Fursa Hub access tokens. Creates new user if first time.
Endpoint: POST {base_url}/auth/firebase/authenticate
Access Level: 🌐 Public
Authentication: None (Firebase token in body)
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
| Content-Type | string | Yes | application/json |
Request JSON Sample:
{
"firebaseToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"preferredLanguage": "sw",
"theme": "DARK",
"deviceInfo": "Android 14, Samsung Galaxy S24"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| firebaseToken | string | Yes | Firebase ID token from client SDK | Must be valid Firebase token |
| preferredLanguage | string | No | User's language preference | 2-5 chars (e.g., "en", "sw", "fr") |
| theme | string | No | UI theme preference | enum: LIGHT, DARK, SYSTEM |
| deviceInfo | string | No | Device information for session tracking | Max 255 chars |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Authentication successful",
"action_time": "2025-01-05T10:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 3600,
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "johndoe",
"phoneNumber": null,
"fullName": "John Doe",
"profilePhotoUrl": "https://lh3.googleusercontent.com/...",
"isPhoneVerified": false,
"isEmailVerified": true,
"preferredLanguage": "sw",
"theme": "DARK",
"authProvider": "GOOGLE",
"role": "ROLE_USER",
"createdAt": "2025-01-05T10:30:45"
},
"onboarding": {
"isComplete": false,
"currentStep": "PENDING_PHONE_VERIFICATION"
}
}
}
Success Response Fields:
| Field | Description |
|---|---|
| accessToken | JWT token for API requests (expires in 1 hour) |
| refreshToken | Token to get new accessToken (expires in 30 days) |
| tokenType | Always "Bearer" |
| expiresIn | Access token lifetime in seconds |
| user | User profile information |
| user.theme | User's theme preference: LIGHT, DARK, or SYSTEM |
| onboarding.isComplete | false = must complete onboarding, true = can access app |
| onboarding.currentStep | Current onboarding step (see Onboarding docs) |
2. Refresh Access Token
Purpose: Get a new access token using refresh token when current one expires.
Endpoint: POST {base_url}/auth/refresh
Access Level: 🌐 Public
Authentication: None (refresh token in body)
Request JSON Sample:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| refreshToken | string | Yes | Refresh token from authentication | Must be valid, non-expired |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Token refreshed successfully",
"action_time": "2025-01-05T11:30:45",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 3600,
"user": { ... },
"onboarding": { ... }
}
}
Error Responses:
Invalid Refresh Token (401):
{
"success": false,
"httpStatus": "UNAUTHORIZED",
"message": "Invalid refresh token",
"action_time": "2025-01-05T11:30:45",
"data": "Invalid refresh token"
}
Expired Refresh Token (401):
{
"success": false,
"httpStatus": "UNAUTHORIZED",
"message": "Refresh token expired",
"action_time": "2025-01-05T11:30:45",
"data": "Refresh token expired"
}
3. Logout
Purpose: Invalidate all refresh tokens for the user, ending all sessions.
Endpoint: POST {base_url}/auth/logout
Access Level: 🔒 Protected
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Yes | Bearer {accessToken} |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Logged out successfully",
"action_time": "2025-01-05T12:00:00",
"data": null
}
4. Get Supported Languages
Purpose: Get list of supported languages for language selector screen.
Endpoint: GET {base_url}/languages
Access Level: 🌐 Public
Authentication: None
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Languages retrieved successfully",
"action_time": "2025-01-05T10:00:00",
"data": [
{
"code": "en",
"name": "English",
"nativeName": "English"
},
{
"code": "sw",
"name": "Swahili",
"nativeName": "Kiswahili"
},
{
"code": "fr",
"name": "French",
"nativeName": "Français"
},
{
"code": "zh",
"name": "Chinese",
"nativeName": "中文"
}
]
}
Frontend Implementation Guide
Step 1: First App Launch
Show language selector screen
├── Call GET /languages to get options
├── User selects language
├── Store locally: selectedLanguage, theme (default: SYSTEM)
└── Navigate to sign-in screen
Step 2: Sign In
Firebase Sign-In
├── Use Firebase SDK (Google/Apple/Email)
├── On success, get Firebase ID token
└── Call POST /auth/firebase/authenticate with:
- firebaseToken
- preferredLanguage (from step 1)
- theme (from step 1)
- deviceInfo (optional)
Step 3: Handle Response
Check response.data.onboarding.isComplete
├── false → Navigate to onboarding flow
│ └── Start at response.data.onboarding.currentStep
└── true → Navigate to home screen
Step 4: Store Tokens
Save securely:
├── accessToken → For Authorization header
├── refreshToken → For token renewal
└── user data → For UI display
Step 5: API Calls
All protected endpoints:
├── Add header: Authorization: Bearer {accessToken}
├── On 401 error → Try refresh token
│ ├── Success → Retry original request
│ └── Fail → Force re-login
└── Continue normal flow
Files Management
Base URL: https://api.fursahub.com/api/v1
Short Description: File upload and management endpoints for Fursa Hub. Handles image uploads for profiles, posts, events, and other content. Uses MinIO for storage with automatic BlurHash generation for images.
Hints:
- Maximum file size: 25MB per file
- Supported image types: JPEG, PNG, GIF, WebP, BMP
- Supported video types: MP4, AVI, MOV, WebM, MKV
- Supported documents: PDF, Word, Excel, Text files
- Each user gets their own storage bucket
- BlurHash automatically generated for images (use for loading placeholders)
- Files are publicly accessible via permanentUrl
File Storage Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ FILE STORAGE ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ UPLOAD FLOW: │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 1. Client uploads file to: POST /files/upload-single │ │
│ │ 2. Backend: │ │
│ │ ├── Validates file (size, type) │ │
│ │ ├── Creates user bucket if needed (fursa-{userId}) │ │
│ │ ├── Generates unique filename (UUID) │ │
│ │ ├── Uploads to MinIO storage │ │
│ │ ├── If image: generates BlurHash + dimensions │ │
│ │ └── Returns file metadata with permanentUrl │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ STORAGE STRUCTURE: │
│ MinIO Server │
│ └── fursa-{userId}/ ← User's bucket │
│ ├── profile/ ← Profile photos │
│ │ └── abc123-uuid.jpg │
│ ├── social_post/ ← Post media │
│ │ ├── def456-uuid.jpg │
│ │ └── ghi789-uuid.mp4 │
│ ├── events/ ← Event images │
│ ├── opportunities/ ← Opportunity attachments │
│ ├── calls/ ← Call for proposals │
│ ├── funds/ ← Fund documents │
│ ├── innovation/ ← Innovation hub files │
│ └── skill_center/ ← Course materials │
│ │
│ ACCESS: │
│ └── Files accessible at: {filesServerUrl}/{bucketName}/{objectKey} │
│ └── Example: https://files.fursahub.com/fursa-uuid123/profile/pic.jpg │
│ │
└─────────────────────────────────────────────────────────────────────────┘
File Directories
| Directory | Purpose | Typical Use |
|---|---|---|
PROFILE |
Profile photos | User avatar, cover images |
SOCIAL_POST |
Social media posts | Post images, videos |
CALLS |
Call for proposals | Proposal documents |
FUNDS |
Funding/grants | Grant application files |
EVENTS |
Events | Event banners, tickets |
OPPORTUNITIES |
Job/opportunity | Job post attachments |
INNOVATION |
Innovation hub | Project files |
SKILL_CENTER |
Skills/courses | Course materials |
Endpoints
1. Upload Single File
Purpose: Upload a single file to the specified directory.
Endpoint: POST {base_url}/files/upload-single
Access Level: 🔒 Protected
Authentication: Bearer Token
Content-Type: multipart/form-data
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| file | file | Yes | File to upload | Max 25MB |
| directory | string | Yes | Target directory | enum: PROFILE, SOCIAL_POST, CALLS, FUNDS, EVENTS, OPPORTUNITIES, INNOVATION, SKILL_CENTER |
Example Request (using curl):
curl -X POST \
-H "Authorization: Bearer {accessToken}" \
-F "file=@/path/to/image.jpg" \
-F "directory=PROFILE" \
{base_url}/files/upload-single
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "File uploaded successfully",
"action_time": "2025-01-05T10:30:45",
"data": {
"fileName": "550e8400-e29b-41d4-a716-446655440000.jpg",
"originalFileName": "my-photo.jpg",
"objectKey": "profile/550e8400-e29b-41d4-a716-446655440000.jpg",
"directory": "PROFILE",
"contentType": "image/jpeg",
"fileSize": 245678,
"fileSizeFormatted": "239.9 KB",
"permanentUrl": "https://files.fursahub.com/fursa-userid/profile/550e8400.jpg",
"thumbnailUrl": "https://files.fursahub.com/fursa-userid/profile/550e8400.jpg",
"blurHash": "LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
"fileExtension": ".jpg",
"fileType": "IMAGE",
"isImage": true,
"isVideo": false,
"isDocument": false,
"width": 1920,
"height": 1080,
"dimensions": "1920x1080",
"checksum": "d41d8cd98f00b204e9800998ecf8427e",
"uploadedAt": "2025-01-05T10:30:45",
"uploadedBy": "550e8400-e29b-41d4-a716-446655440000",
"isPublic": true
}
}
Success Response Fields:
| Field | Description |
|---|---|
| fileName | Generated unique filename |
| originalFileName | Original uploaded filename |
| objectKey | Full path in storage (directory/filename) |
| directory | Storage directory |
| permanentUrl | Public URL to access the file |
| thumbnailUrl | Thumbnail URL (same as permanentUrl for images) |
| blurHash | BlurHash string for image placeholders (null for non-images) |
| fileType | IMAGE, VIDEO, DOCUMENT, or OTHER |
| width, height | Image dimensions (null for non-images) |
| checksum | MD5 checksum for integrity verification |
Error Responses:
File Too Large (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "File size exceeds maximum limit of 25MB",
"action_time": "2025-01-05T10:30:45",
"data": "File size exceeds maximum limit of 25MB"
}
Empty File (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "File is empty",
"action_time": "2025-01-05T10:30:45",
"data": "File is empty"
}
2. Upload Multiple Files
Purpose: Upload multiple files at once to the same directory.
Endpoint: POST {base_url}/files/upload
Access Level: 🔒 Protected
Authentication: Bearer Token
Content-Type: multipart/form-data
Request Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| files | file[] | Yes | Array of files | Max 25MB each |
| directory | string | Yes | Target directory | enum values |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Files uploaded successfully",
"action_time": "2025-01-05T10:35:00",
"data": {
"uploadedFiles": [
{
"fileName": "uuid1.jpg",
"originalFileName": "photo1.jpg",
"permanentUrl": "https://files.fursahub.com/bucket/path/uuid1.jpg",
"blurHash": "LKO2?U%2Tw=w]...",
"isImage": true
},
{
"fileName": "uuid2.jpg",
"originalFileName": "photo2.jpg",
"permanentUrl": "https://files.fursahub.com/bucket/path/uuid2.jpg",
"blurHash": "LAB2?Q%1Tu=x]...",
"isImage": true
}
],
"totalFiles": 2,
"successfulUploads": 2,
"failedUploads": 0,
"totalSize": 512000,
"totalSizeFormatted": "500.0 KB",
"uploadedAt": "2025-01-05T10:35:00",
"message": "2 files uploaded successfully",
"errors": null
}
}
Partial Success Response (some files failed):
{
"success": true,
"httpStatus": "OK",
"message": "Files uploaded successfully",
"action_time": "2025-01-05T10:35:00",
"data": {
"uploadedFiles": [...],
"totalFiles": 3,
"successfulUploads": 2,
"failedUploads": 1,
"message": "2 files uploaded successfully, 1 failed",
"errors": [
"Failed to upload large-file.zip: File size exceeds maximum limit of 25MB"
]
}
}
BlurHash Usage
BlurHash is a compact representation of an image placeholder. Use it to show a blurred preview while the actual image loads.
Example BlurHash: LKO2?U%2Tw=w]~RBVZRi};RPxuwH
Frontend Implementation:
// React example with blurhash library
import { Blurhash } from "react-blurhash";
function ImageWithPlaceholder({ imageUrl, blurHash, width, height }) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: 'relative' }}>
{!loaded && blurHash && (
<Blurhash
hash={blurHash}
width={width}
height={height}
resolutionX={32}
resolutionY={32}
/>
)}
<img
src={imageUrl}
onLoad={() => setLoaded(true)}
style={{ display: loaded ? 'block' : 'none' }}
/>
</div>
);
}
Frontend Implementation Guide
Profile Photo Upload
1. User selects photo
2. POST /files/upload-single
├── file: selected image
└── directory: "PROFILE"
3. Get permanentUrl from response
4. POST /profile/photo with photoUrl
5. Display image with blurHash placeholder
Social Post with Images
1. User creates post, selects images
2. For each image: POST /files/upload-single
├── file: image
└── directory: "SOCIAL_POST"
3. Collect all permanentUrls
4. Create post with image URLs array
5. Store blurHash for each image for feed display
File Upload Component
function uploadFile(file, directory) {
const formData = new FormData();
formData.append('file', file);
formData.append('directory', directory);
return fetch('{base_url}/files/upload-single', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
},
body: formData
});
}
Validation Before Upload
const MAX_SIZE = 25 * 1024 * 1024; // 25MB
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
function validateFile(file) {
if (file.size > MAX_SIZE) {
throw new Error('File too large (max 25MB)');
}
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
throw new Error('Invalid file type');
}
return true;
}
Profile
Base URL: https://api.fursahub.com/api/v1
Short Description: Profile management endpoints for Fursa Hub. Allows users to view and update their profile information, manage photos, change theme and language preferences. Profile completion is the final step of onboarding.
Hints:
- All profile endpoints require authentication (Bearer token)
- Username must be unique and can only contain letters, numbers, and underscores
- Profile photos should be uploaded via Files API first, then URLs added here
- Completing profile (fullName + username + bio) auto-completes onboarding if at final step
- Theme changes take effect immediately on client side
Profile in Onboarding Context
┌─────────────────────────────────────────────────────────────────────────┐
│ PROFILE & ONBOARDING RELATIONSHIP │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Profile data comes from multiple sources: │
│ │
│ 1. FIREBASE (auto-filled at registration) │
│ └── fullName (from Google/Apple account) │
│ └── profilePhotoUrl (from social account) │
│ └── email (from auth provider) │
│ │
│ 2. REGISTRATION (user choice) │
│ └── preferredLanguage │
│ └── theme │
│ │
│ 3. ONBOARDING (user completes) │
│ └── phoneNumber (verified via OTP) │
│ └── preferences (from onboarding pages) │
│ │
│ 4. PROFILE COMPLETION (final step) │
│ └── username (required, unique) │
│ └── bio (required) │
│ └── fullName (can edit Firebase default) │
│ └── gender, link (optional) │
│ │
│ When user completes: fullName + username + bio │
│ └── Onboarding auto-completes if at PENDING_PROFILE_COMPLETION │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Endpoints
1. Get Profile
Purpose: Retrieve current user's complete profile information.
Endpoint: GET {base_url}/profile
Access Level: 🔒 Protected
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Yes | Bearer {accessToken} |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Profile retrieved",
"action_time": "2025-01-05T10:30:45",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "johndoe",
"phoneNumber": "+255712345678",
"fullName": "John Doe",
"bio": "Software developer passionate about tech",
"gender": "MALE",
"link": "https://johndoe.com",
"profilePhotoUrls": [
"https://files.fursahub.com/bucket/profile/photo1.jpg",
"https://files.fursahub.com/bucket/profile/photo2.jpg"
],
"primaryPhotoUrl": "https://files.fursahub.com/bucket/profile/photo1.jpg",
"isPhoneVerified": true,
"isEmailVerified": true,
"preferredLanguage": "sw",
"theme": "DARK",
"authProvider": "GOOGLE",
"role": "ROLE_USER",
"onboardingStatus": "COMPLETED",
"isOnboardingComplete": true,
"createdAt": "2025-01-01T08:00:00",
"updatedAt": "2025-01-05T10:30:00"
}
}
Success Response Fields:
| Field | Description |
|---|---|
| id | Unique user identifier (UUID) |
| User's email address | |
| username | Unique username (lowercase) |
| phoneNumber | Verified phone number in E.164 format |
| fullName | Display name |
| bio | User biography/description |
| gender | MALE or FEMALE |
| link | Personal website or social link |
| profilePhotoUrls | Array of all profile photo URLs |
| primaryPhotoUrl | First photo URL (main profile picture) |
| isPhoneVerified | Phone verification status |
| isEmailVerified | Email verification status |
| preferredLanguage | Language code (en, sw, fr, zh) |
| theme | LIGHT, DARK, or SYSTEM |
| authProvider | GOOGLE, APPLE, or EMAIL |
| role | User role (ROLE_USER, ROLE_ADMIN, etc.) |
| onboardingStatus | Current onboarding step |
| isOnboardingComplete | true if onboarding finished |
2. Update Profile
Purpose: Update user profile information. Only provided fields are updated.
Endpoint: PUT {base_url}/profile
Access Level: 🔒 Protected
Authentication: Bearer Token
Request Headers:
| Header | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Yes | Bearer {accessToken} |
| Content-Type | string | Yes | application/json |
Request JSON Sample:
{
"fullName": "John Doe Updated",
"username": "johndoe_new",
"bio": "Building the future of opportunity in East Africa",
"gender": "MALE",
"link": "https://linkedin.com/in/johndoe",
"profilePhotoUrls": [
"https://files.fursahub.com/bucket/profile/new-photo.jpg"
],
"theme": "LIGHT",
"preferredLanguage": "en"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| fullName | string | No | Display name | Min: 2, Max: 100 chars |
| username | string | No | Unique username | Min: 3, Max: 30 chars, alphanumeric + underscore only |
| bio | string | No | User biography | Max: 500 chars |
| gender | string | No | User gender | enum: MALE, FEMALE |
| link | string | No | Personal/social link | Must be valid URL (https://...) |
| profilePhotoUrls | array | No | List of photo URLs | Array of strings |
| theme | string | No | UI theme preference | enum: LIGHT, DARK, SYSTEM |
| preferredLanguage | string | No | Language preference | Must be active language code |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Profile updated",
"action_time": "2025-01-05T10:35:00",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "johndoe_new",
"fullName": "John Doe Updated",
"bio": "Building the future of opportunity in East Africa",
"theme": "LIGHT",
"preferredLanguage": "en",
"onboardingStatus": "COMPLETED",
"isOnboardingComplete": true
}
}
Error Responses:
Username Already Taken (409):
{
"success": false,
"httpStatus": "CONFLICT",
"message": "Username already taken",
"action_time": "2025-01-05T10:35:00",
"data": "Username already taken"
}
Invalid Language Code (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Invalid or inactive language code: xx",
"action_time": "2025-01-05T10:35:00",
"data": "Invalid or inactive language code: xx"
}
Validation Error (422):
{
"success": false,
"httpStatus": "UNPROCESSABLE_ENTITY",
"message": "Validation failed",
"action_time": "2025-01-05T10:35:00",
"data": {
"username": "Username can only contain letters, numbers, and underscores",
"fullName": "Name must be 2-100 characters"
}
}
3. Update Theme (Quick Toggle)
Purpose: Quick endpoint to change theme without full profile update.
Endpoint: PATCH {base_url}/profile/theme
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"theme": "DARK"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| theme | string | Yes | Theme preference | enum: LIGHT, DARK, SYSTEM |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Theme updated",
"action_time": "2025-01-05T10:40:00",
"data": {
"theme": "DARK"
}
}
4. Check Username Availability
Purpose: Check if a username is available before updating profile.
Endpoint: GET {base_url}/profile/username/check
Access Level: 🔒 Protected
Authentication: Bearer Token
Query Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| username | string | Yes | Username to check | Min: 3 chars |
Success Response JSON Sample (Available):
{
"success": true,
"httpStatus": "OK",
"message": "Username available",
"action_time": "2025-01-05T10:45:00",
"data": {
"username": "newusername",
"available": true
}
}
Success Response JSON Sample (Taken):
{
"success": true,
"httpStatus": "OK",
"message": "Username taken",
"action_time": "2025-01-05T10:45:00",
"data": {
"username": "existinguser",
"available": false
}
}
5. Add Profile Photo
Purpose: Add a new photo to user's profile photos array.
Endpoint: POST {base_url}/profile/photo
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"photoUrl": "https://files.fursahub.com/bucket/profile/new-photo.jpg"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| photoUrl | string | Yes | URL of uploaded photo | Must be valid URL |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Photo added",
"action_time": "2025-01-05T10:50:00",
"data": {
"profilePhotoUrls": [
"https://files.fursahub.com/bucket/profile/photo1.jpg",
"https://files.fursahub.com/bucket/profile/new-photo.jpg"
],
"primaryPhotoUrl": "https://files.fursahub.com/bucket/profile/photo1.jpg"
}
}
6. Remove Profile Photo
Purpose: Remove a photo from user's profile photos array.
Endpoint: DELETE {base_url}/profile/photo
Access Level: 🔒 Protected
Authentication: Bearer Token
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| photoUrl | string | Yes | URL of photo to remove |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Photo removed",
"action_time": "2025-01-05T10:55:00",
"data": {
"profilePhotoUrls": [
"https://files.fursahub.com/bucket/profile/photo1.jpg"
],
"primaryPhotoUrl": "https://files.fursahub.com/bucket/profile/photo1.jpg"
}
}
Frontend Implementation Guide
Profile Completion Screen
When onboardingStatus = "PENDING_PROFILE_COMPLETION":
1. GET /profile to fetch current data
2. Show form with:
├── fullName (pre-filled from Firebase)
├── username (auto-generated, editable)
│ └── On change: GET /profile/username/check
├── bio (required)
├── gender (optional)
└── link (optional)
3. PUT /profile with form data
4. If isOnboardingComplete = true → Navigate to home
Settings Screen
Theme Toggle:
└── PATCH /profile/theme with selected theme
└── Apply theme immediately on client
Language Change:
└── PUT /profile with preferredLanguage
└── Reload UI with new language strings
Profile Edit:
└── PUT /profile with changed fields only
Photo Management
Adding Photo:
1. Upload via Files API (POST /files/upload-single)
2. Get permanentUrl from response
3. POST /profile/photo with photoUrl
Removing Photo:
1. DELETE /profile/photo?photoUrl=...
2. Optionally delete from Files API
Onboarding EndUser
Base URL: https://api.fursahub.com/api/v1
Short Description: Onboarding flow endpoints for Fursa Hub. Guides new users through phone verification, preference selection, and profile completion. All content is translated based on user's preferredLanguage setting.
Hints:
- Onboarding steps must be completed in order - skipping ahead returns 412 PRECONDITION_FAILED
- Phone verification uses OTP sent via SMS (supports TZ, KE, UG, RW, BI country codes)
- Preference pages are dynamic - admin can add/remove/reorder without code changes
- User's preferredLanguage determines translation of all onboarding content
- Email verification is optional/skippable by default (Firebase handles actual verification)
Complete Onboarding Flow
┌─────────────────────────────────────────────────────────────────────────┐
│ COMPLETE ONBOARDING FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ After POST /auth/firebase/authenticate: │
│ └── Check response.data.onboarding.currentStep │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 1: PENDING_EMAIL_VERIFICATION (Optional/Skippable) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • Firebase handles actual email verification │ │
│ │ • Check: GET /onboarding/email-verification/status │ │
│ │ • Skip: POST /onboarding/email-verification/skip │ │
│ │ • Or wait for user to verify email in Firebase │ │
│ │ • Auto-transitions when Firebase reports email verified │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 2: PENDING_PHONE_VERIFICATION (Required) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • POST /onboarding/auth-phone/request-otp │ │
│ │ └── User enters phone number (+255...) │ │
│ │ └── Backend sends OTP via SMS │ │
│ │ └── Returns token for verification │ │
│ │ • POST /onboarding/auth-phone/verify │ │
│ │ └── User enters 6-digit OTP │ │
│ │ └── On success: phone saved, transitions to next step │ │
│ │ • POST /onboarding/auth-phone/resend-otp (if needed) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 3: PENDING_PREFERENCES (Required) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • GET /onboarding/pages?current=true │ │
│ │ └── Get current preference page │ │
│ │ • Loop through pages: │ │
│ │ └── Display options (translated to user's language) │ │
│ │ └── User selects options │ │
│ │ └── POST /onboarding/pages/{pageId}/response │ │
│ │ └── Or POST /onboarding/pages/{pageId}/skip (if skippable) │ │
│ │ └── Move to next page until all complete │ │
│ │ • Auto-transitions when all pages completed │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 4: PENDING_PROFILE_COMPLETION (Required) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • GET /profile │ │
│ │ └── Get current profile (some data from Firebase) │ │
│ │ • PUT /profile │ │
│ │ └── fullName (required) │ │
│ │ └── username (required, unique) │ │
│ │ └── bio (required) │ │
│ │ └── gender, link (optional) │ │
│ │ • On save: auto-completes onboarding │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 5: COMPLETED ✓ │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • Navigate to home screen │ │
│ │ • User can now access all app features │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Phone Verification Endpoints
1. Request OTP
Purpose: Send OTP code to user's phone number for verification.
Endpoint: POST {base_url}/onboarding/auth-phone/request-otp
Access Level: 🔒 Protected
Authentication: Bearer Token
Prerequisite: User must be at PENDING_PHONE_VERIFICATION step
Request JSON Sample:
{
"phoneNumber": "+255712345678"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| phoneNumber | string | Yes | Phone number with country code | E.164 format: +255XXXXXXXXX |
Supported Country Codes:
+255- Tanzania+254- Kenya+256- Uganda+250- Rwanda+257- Burundi
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "OTP sent successfully",
"action_time": "2025-01-05T10:30:45",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"phoneNumber": "+255****678",
"expiresInSeconds": 600,
"resendAvailableIn": 120
}
}
Success Response Fields:
| Field | Description |
|---|---|
| token | Temporary token for verify/resend endpoints |
| phoneNumber | Masked phone number for display |
| expiresInSeconds | OTP validity period (10 minutes) |
| resendAvailableIn | Seconds until resend allowed (2 minutes) |
Error Responses:
Wrong Onboarding Step (412):
{
"success": false,
"httpStatus": "PRECONDITION_FAILED",
"message": "Onboarding step required",
"action_time": "2025-01-05T10:30:45",
"data": {
"message": "Complete email verification first",
"currentStep": "PENDING_EMAIL_VERIFICATION",
"requiredStep": "PENDING_EMAIL_VERIFICATION"
}
}
Rate Limit Exceeded (429):
{
"success": false,
"httpStatus": "TOO_MANY_REQUESTS",
"message": "Too many OTP requests. Try again in 10 minutes.",
"action_time": "2025-01-05T10:30:45",
"data": "Too many OTP requests. Try again in 10 minutes."
}
Phone Already Registered (409):
{
"success": false,
"httpStatus": "CONFLICT",
"message": "Phone number already registered",
"action_time": "2025-01-05T10:30:45",
"data": "Phone number already registered"
}
2. Verify OTP
Purpose: Verify OTP code and complete phone verification.
Endpoint: POST {base_url}/onboarding/auth-phone/verify
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"otp": "123456"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| token | string | Yes | Token from request-otp response | Must be valid, non-expired |
| otp | string | Yes | 6-digit OTP code | Exactly 6 digits |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Phone verified successfully",
"action_time": "2025-01-05T10:35:00",
"data": {
"verified": true,
"phoneNumber": "+255****678",
"onboardingStatus": "PENDING_PREFERENCES",
"nextStep": "/api/v1/onboarding/pages"
}
}
Error Responses:
Invalid OTP (403):
{
"success": false,
"httpStatus": "FORBIDDEN",
"message": "Invalid OTP. 2 attempt(s) remaining.",
"action_time": "2025-01-05T10:35:00",
"data": "Invalid OTP. 2 attempt(s) remaining."
}
Expired OTP (403):
{
"success": false,
"httpStatus": "FORBIDDEN",
"message": "OTP has expired. Please request a new one.",
"action_time": "2025-01-05T10:35:00",
"data": "OTP has expired. Please request a new one."
}
Max Attempts Reached (403):
{
"success": false,
"httpStatus": "FORBIDDEN",
"message": "Maximum attempts reached. Please request a new OTP.",
"action_time": "2025-01-05T10:35:00",
"data": "Maximum attempts reached. Please request a new OTP."
}
3. Resend OTP
Purpose: Request a new OTP code using the existing token.
Endpoint: POST {base_url}/onboarding/auth-phone/resend-otp
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Success Response: Same as Request OTP endpoint
Email Verification Endpoints
4. Get Email Verification Status
Purpose: Check email verification status and options.
Endpoint: GET {base_url}/onboarding/email-verification/status
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Email verification status",
"action_time": "2025-01-05T10:00:00",
"data": {
"verified": false,
"email": "jo***@example.com",
"required": false,
"canSkip": true,
"currentStep": "PENDING_EMAIL_VERIFICATION"
}
}
5. Skip Email Verification
Purpose: Skip email verification step (if allowed by config).
Endpoint: POST {base_url}/onboarding/email-verification/skip
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Email verification skipped",
"action_time": "2025-01-05T10:05:00",
"data": {
"verified": false,
"skipped": true,
"nextStep": "PENDING_PHONE_VERIFICATION"
}
}
Preference Pages Endpoints
6. Get Onboarding Progress
Purpose: Get overall onboarding progress with all steps.
Endpoint: GET {base_url}/onboarding/progress
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Progress retrieved",
"action_time": "2025-01-05T10:40:00",
"data": {
"percentage": 45.0,
"currentStage": "PENDING_PREFERENCES",
"currentStageLabel": "Complete your preferences",
"steps": [
{
"key": "registration",
"label": "Registration",
"completed": true,
"weight": 15.0,
"skippable": false
},
{
"key": "phone_verification",
"label": "Phone Verification",
"completed": true,
"weight": 15.0,
"skippable": false
},
{
"key": "page_interests",
"label": "Your Interests",
"completed": true,
"weight": 13.33,
"skippable": false
},
{
"key": "page_goals",
"label": "Your Goals",
"completed": false,
"weight": 13.33,
"skippable": true
},
{
"key": "page_experience",
"label": "Your Experience",
"completed": false,
"weight": 13.33,
"skippable": false
},
{
"key": "profile_completion",
"label": "Complete Profile",
"completed": false,
"weight": 15.0,
"skippable": false
}
],
"nextStep": {
"key": "page_goals",
"label": "Your Goals",
"endpoint": "/api/v1/onboarding/pages?page=2",
"skippable": true
}
}
}
7. Get All Pages
Purpose: Get all preference pages with completion status.
Endpoint: GET {base_url}/onboarding/pages
Access Level: 🔒 Protected
Authentication: Bearer Token
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| all | boolean | No | Return all pages (default behavior) |
| page | integer | No | Get specific page by order (1, 2, 3...) |
| category | string | No | Get page by category key |
| current | boolean | No | Get first incomplete page |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "All pages retrieved",
"action_time": "2025-01-05T10:45:00",
"data": {
"totalPages": 3,
"completedPages": 1,
"isOnboardingComplete": false,
"pages": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"pageOrder": 1,
"categoryKey": "interests",
"title": "Maslahi Yako",
"description": "Chagua mambo yanayokuvutia",
"bannerImages": ["https://cdn.fursahub.com/onboarding/interests.jpg"],
"isSkippable": false,
"minSelections": 1,
"maxSelections": 5,
"options": [
{ "key": "jobs", "label": "Kazi", "icon": "briefcase" },
{ "key": "funding", "label": "Ufadhili", "icon": "dollar" },
{ "key": "events", "label": "Matukio", "icon": "calendar" },
{ "key": "skills", "label": "Ujuzi", "icon": "book" },
{ "key": "networking", "label": "Mitandao", "icon": "users" }
],
"isCompleted": true
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"pageOrder": 2,
"categoryKey": "goals",
"title": "Malengo Yako",
"description": "Unataka kufikia nini?",
"isSkippable": true,
"minSelections": 1,
"maxSelections": 3,
"options": [
{ "key": "find_job", "label": "Kupata kazi", "icon": "search" },
{ "key": "start_business", "label": "Kuanzisha biashara", "icon": "store" },
{ "key": "learn_skills", "label": "Kujifunza ujuzi", "icon": "graduation" },
{ "key": "get_funding", "label": "Kupata ufadhili", "icon": "money" }
],
"isCompleted": false
}
]
}
}
8. Get Current Page
Purpose: Get the first incomplete preference page.
Endpoint: GET {base_url}/onboarding/pages?current=true
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Current page retrieved",
"action_time": "2025-01-05T10:50:00",
"data": {
"page": {
"id": "550e8400-e29b-41d4-a716-446655440002",
"pageOrder": 2,
"categoryKey": "goals",
"title": "Malengo Yako",
"description": "Unataka kufikia nini?",
"isSkippable": true,
"minSelections": 1,
"maxSelections": 3,
"options": [...]
},
"progress": {
"current": 2,
"total": 3,
"nextPage": 3,
"isLast": false,
"isCompleted": false
}
}
}
9. Submit Page Response
Purpose: Save user's selections for a preference page.
Endpoint: POST {base_url}/onboarding/pages/{pageId}/response
Access Level: 🔒 Protected
Authentication: Bearer Token
Path Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| pageId | UUID | Yes | Page identifier |
Request JSON Sample:
{
"selectedOptions": ["find_job", "learn_skills"]
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| selectedOptions | array | Yes | Array of selected option keys | Must match page's min/max selections |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Response saved",
"action_time": "2025-01-05T10:55:00",
"data": {
"saved": true,
"progress": {
"current": 2,
"total": 3,
"nextPage": 3,
"isLast": false,
"isCompleted": false
}
}
}
Error Responses:
Too Few Selections (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Minimum 1 selection(s) required",
"action_time": "2025-01-05T10:55:00",
"data": "Minimum 1 selection(s) required"
}
Invalid Option (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Invalid option: unknown_key",
"action_time": "2025-01-05T10:55:00",
"data": "Invalid option: unknown_key"
}
10. Skip Page
Purpose: Skip a preference page (only if page is skippable).
Endpoint: POST {base_url}/onboarding/pages/{pageId}/skip
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Page skipped",
"action_time": "2025-01-05T11:00:00",
"data": {
"saved": true,
"progress": {
"current": 2,
"total": 3,
"nextPage": 3,
"isLast": false,
"isCompleted": false
}
}
}
Error Response (Page Not Skippable):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "This page cannot be skipped",
"action_time": "2025-01-05T11:00:00",
"data": "This page cannot be skipped"
}
Language Preference Endpoint
11. Set Language Preference
Purpose: Update user's language preference (can be called anytime during onboarding).
Endpoint: POST {base_url}/onboarding/language-preference
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"code": "sw"
}
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Language preference updated",
"action_time": "2025-01-05T11:05:00",
"data": {
"code": "sw",
"name": "Swahili",
"nativeName": "Kiswahili"
}
}
Frontend Implementation Guide
Phone Verification Screen
1. Show phone input with country code selector
2. On submit: POST /onboarding/auth-phone/request-otp
3. Save token from response
4. Navigate to OTP input screen
5. Show countdown for resendAvailableIn
6. On OTP submit: POST /onboarding/auth-phone/verify
7. On success: Navigate based on nextStep
Preference Pages Loop
1. GET /onboarding/pages?current=true
2. If page is null → All done, navigate to profile
3. Display page with:
├── Title, description (translated)
├── Banner image
├── Options as selectable chips/cards
└── Skip button (if isSkippable)
4. On submit: POST /onboarding/pages/{pageId}/response
5. Check progress.isCompleted
├── true → Navigate to profile completion
└── false → GET /onboarding/pages?current=true (loop)
Progress Indicator
GET /onboarding/progress
├── Use percentage for progress bar
├── Show steps as dots/icons
├── Highlight current step
└── Show completed steps with checkmarks
Onboarding Analytics Admin
Base URL: https://api.fursahub.com/api/v1
Short Description: Onboarding flow endpoints for Fursa Hub. Guides new users through phone verification, preference selection, and profile completion. All content is translated based on user's preferredLanguage setting.
Hints:
- Onboarding steps must be completed in order - skipping ahead returns 412 PRECONDITION_FAILED
- Phone verification uses OTP sent via SMS (supports TZ, KE, UG, RW, BI country codes)
- Preference pages are dynamic - admin can add/remove/reorder without code changes
- User's preferredLanguage determines translation of all onboarding content
- Email verification is optional/skippable by default (Firebase handles actual verification)
Complete Onboarding Flow
┌─────────────────────────────────────────────────────────────────────────┐
│ COMPLETE ONBOARDING FLOW │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ After POST /auth/firebase/authenticate: │
│ └── Check response.data.onboarding.currentStep │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 1: PENDING_EMAIL_VERIFICATION (Optional/Skippable) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • Firebase handles actual email verification │ │
│ │ • Check: GET /onboarding/email-verification/status │ │
│ │ • Skip: POST /onboarding/email-verification/skip │ │
│ │ • Or wait for user to verify email in Firebase │ │
│ │ • Auto-transitions when Firebase reports email verified │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 2: PENDING_PHONE_VERIFICATION (Required) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • POST /onboarding/auth-phone/request-otp │ │
│ │ └── User enters phone number (+255...) │ │
│ │ └── Backend sends OTP via SMS │ │
│ │ └── Returns token for verification │ │
│ │ • POST /onboarding/auth-phone/verify │ │
│ │ └── User enters 6-digit OTP │ │
│ │ └── On success: phone saved, transitions to next step │ │
│ │ • POST /onboarding/auth-phone/resend-otp (if needed) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 3: PENDING_PREFERENCES (Required) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • GET /onboarding/pages?current=true │ │
│ │ └── Get current preference page │ │
│ │ • Loop through pages: │ │
│ │ └── Display options (translated to user's language) │ │
│ │ └── User selects options │ │
│ │ └── POST /onboarding/pages/{pageId}/response │ │
│ │ └── Or POST /onboarding/pages/{pageId}/skip (if skippable) │ │
│ │ └── Move to next page until all complete │ │
│ │ • Auto-transitions when all pages completed │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 4: PENDING_PROFILE_COMPLETION (Required) │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • GET /profile │ │
│ │ └── Get current profile (some data from Firebase) │ │
│ │ • PUT /profile │ │
│ │ └── fullName (required) │ │
│ │ └── username (required, unique) │ │
│ │ └── bio (required) │ │
│ │ └── gender, link (optional) │ │
│ │ • On save: auto-completes onboarding │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ STEP 5: COMPLETED ✓ │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ • Navigate to home screen │ │
│ │ • User can now access all app features │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Phone Verification Endpoints
1. Request OTP
Purpose: Send OTP code to user's phone number for verification.
Endpoint: POST {base_url}/onboarding/auth-phone/request-otp
Access Level: 🔒 Protected
Authentication: Bearer Token
Prerequisite: User must be at PENDING_PHONE_VERIFICATION step
Request JSON Sample:
{
"phoneNumber": "+255712345678"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| phoneNumber | string | Yes | Phone number with country code | E.164 format: +255XXXXXXXXX |
Supported Country Codes:
+255- Tanzania+254- Kenya+256- Uganda+250- Rwanda+257- Burundi
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "OTP sent successfully",
"action_time": "2025-01-05T10:30:45",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"phoneNumber": "+255****678",
"expiresInSeconds": 600,
"resendAvailableIn": 120
}
}
Success Response Fields:
| Field | Description |
|---|---|
| token | Temporary token for verify/resend endpoints |
| phoneNumber | Masked phone number for display |
| expiresInSeconds | OTP validity period (10 minutes) |
| resendAvailableIn | Seconds until resend allowed (2 minutes) |
Error Responses:
Wrong Onboarding Step (412):
{
"success": false,
"httpStatus": "PRECONDITION_FAILED",
"message": "Onboarding step required",
"action_time": "2025-01-05T10:30:45",
"data": {
"message": "Complete email verification first",
"currentStep": "PENDING_EMAIL_VERIFICATION",
"requiredStep": "PENDING_EMAIL_VERIFICATION"
}
}
Rate Limit Exceeded (429):
{
"success": false,
"httpStatus": "TOO_MANY_REQUESTS",
"message": "Too many OTP requests. Try again in 10 minutes.",
"action_time": "2025-01-05T10:30:45",
"data": "Too many OTP requests. Try again in 10 minutes."
}
Phone Already Registered (409):
{
"success": false,
"httpStatus": "CONFLICT",
"message": "Phone number already registered",
"action_time": "2025-01-05T10:30:45",
"data": "Phone number already registered"
}
2. Verify OTP
Purpose: Verify OTP code and complete phone verification.
Endpoint: POST {base_url}/onboarding/auth-phone/verify
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"otp": "123456"
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| token | string | Yes | Token from request-otp response | Must be valid, non-expired |
| otp | string | Yes | 6-digit OTP code | Exactly 6 digits |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Phone verified successfully",
"action_time": "2025-01-05T10:35:00",
"data": {
"verified": true,
"phoneNumber": "+255****678",
"onboardingStatus": "PENDING_PREFERENCES",
"nextStep": "/api/v1/onboarding/pages"
}
}
Error Responses:
Invalid OTP (403):
{
"success": false,
"httpStatus": "FORBIDDEN",
"message": "Invalid OTP. 2 attempt(s) remaining.",
"action_time": "2025-01-05T10:35:00",
"data": "Invalid OTP. 2 attempt(s) remaining."
}
Expired OTP (403):
{
"success": false,
"httpStatus": "FORBIDDEN",
"message": "OTP has expired. Please request a new one.",
"action_time": "2025-01-05T10:35:00",
"data": "OTP has expired. Please request a new one."
}
Max Attempts Reached (403):
{
"success": false,
"httpStatus": "FORBIDDEN",
"message": "Maximum attempts reached. Please request a new OTP.",
"action_time": "2025-01-05T10:35:00",
"data": "Maximum attempts reached. Please request a new OTP."
}
3. Resend OTP
Purpose: Request a new OTP code using the existing token.
Endpoint: POST {base_url}/onboarding/auth-phone/resend-otp
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Success Response: Same as Request OTP endpoint
Email Verification Endpoints
4. Get Email Verification Status
Purpose: Check email verification status and options.
Endpoint: GET {base_url}/onboarding/email-verification/status
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Email verification status",
"action_time": "2025-01-05T10:00:00",
"data": {
"verified": false,
"email": "jo***@example.com",
"required": false,
"canSkip": true,
"currentStep": "PENDING_EMAIL_VERIFICATION"
}
}
5. Skip Email Verification
Purpose: Skip email verification step (if allowed by config).
Endpoint: POST {base_url}/onboarding/email-verification/skip
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Email verification skipped",
"action_time": "2025-01-05T10:05:00",
"data": {
"verified": false,
"skipped": true,
"nextStep": "PENDING_PHONE_VERIFICATION"
}
}
Preference Pages Endpoints
6. Get Onboarding Progress
Purpose: Get overall onboarding progress with all steps.
Endpoint: GET {base_url}/onboarding/progress
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Progress retrieved",
"action_time": "2025-01-05T10:40:00",
"data": {
"percentage": 45.0,
"currentStage": "PENDING_PREFERENCES",
"currentStageLabel": "Complete your preferences",
"steps": [
{
"key": "registration",
"label": "Registration",
"completed": true,
"weight": 15.0,
"skippable": false
},
{
"key": "phone_verification",
"label": "Phone Verification",
"completed": true,
"weight": 15.0,
"skippable": false
},
{
"key": "page_interests",
"label": "Your Interests",
"completed": true,
"weight": 13.33,
"skippable": false
},
{
"key": "page_goals",
"label": "Your Goals",
"completed": false,
"weight": 13.33,
"skippable": true
},
{
"key": "page_experience",
"label": "Your Experience",
"completed": false,
"weight": 13.33,
"skippable": false
},
{
"key": "profile_completion",
"label": "Complete Profile",
"completed": false,
"weight": 15.0,
"skippable": false
}
],
"nextStep": {
"key": "page_goals",
"label": "Your Goals",
"endpoint": "/api/v1/onboarding/pages?page=2",
"skippable": true
}
}
}
7. Get All Pages
Purpose: Get all preference pages with completion status.
Endpoint: GET {base_url}/onboarding/pages
Access Level: 🔒 Protected
Authentication: Bearer Token
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| all | boolean | No | Return all pages (default behavior) |
| page | integer | No | Get specific page by order (1, 2, 3...) |
| category | string | No | Get page by category key |
| current | boolean | No | Get first incomplete page |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "All pages retrieved",
"action_time": "2025-01-05T10:45:00",
"data": {
"totalPages": 3,
"completedPages": 1,
"isOnboardingComplete": false,
"pages": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"pageOrder": 1,
"categoryKey": "interests",
"title": "Maslahi Yako",
"description": "Chagua mambo yanayokuvutia",
"bannerImages": ["https://cdn.fursahub.com/onboarding/interests.jpg"],
"isSkippable": false,
"minSelections": 1,
"maxSelections": 5,
"options": [
{ "key": "jobs", "label": "Kazi", "icon": "briefcase" },
{ "key": "funding", "label": "Ufadhili", "icon": "dollar" },
{ "key": "events", "label": "Matukio", "icon": "calendar" },
{ "key": "skills", "label": "Ujuzi", "icon": "book" },
{ "key": "networking", "label": "Mitandao", "icon": "users" }
],
"isCompleted": true
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"pageOrder": 2,
"categoryKey": "goals",
"title": "Malengo Yako",
"description": "Unataka kufikia nini?",
"isSkippable": true,
"minSelections": 1,
"maxSelections": 3,
"options": [
{ "key": "find_job", "label": "Kupata kazi", "icon": "search" },
{ "key": "start_business", "label": "Kuanzisha biashara", "icon": "store" },
{ "key": "learn_skills", "label": "Kujifunza ujuzi", "icon": "graduation" },
{ "key": "get_funding", "label": "Kupata ufadhili", "icon": "money" }
],
"isCompleted": false
}
]
}
}
8. Get Current Page
Purpose: Get the first incomplete preference page.
Endpoint: GET {base_url}/onboarding/pages?current=true
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Current page retrieved",
"action_time": "2025-01-05T10:50:00",
"data": {
"page": {
"id": "550e8400-e29b-41d4-a716-446655440002",
"pageOrder": 2,
"categoryKey": "goals",
"title": "Malengo Yako",
"description": "Unataka kufikia nini?",
"isSkippable": true,
"minSelections": 1,
"maxSelections": 3,
"options": [...]
},
"progress": {
"current": 2,
"total": 3,
"nextPage": 3,
"isLast": false,
"isCompleted": false
}
}
}
9. Submit Page Response
Purpose: Save user's selections for a preference page.
Endpoint: POST {base_url}/onboarding/pages/{pageId}/response
Access Level: 🔒 Protected
Authentication: Bearer Token
Path Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| pageId | UUID | Yes | Page identifier |
Request JSON Sample:
{
"selectedOptions": ["find_job", "learn_skills"]
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| selectedOptions | array | Yes | Array of selected option keys | Must match page's min/max selections |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Response saved",
"action_time": "2025-01-05T10:55:00",
"data": {
"saved": true,
"progress": {
"current": 2,
"total": 3,
"nextPage": 3,
"isLast": false,
"isCompleted": false
}
}
}
Error Responses:
Too Few Selections (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Minimum 1 selection(s) required",
"action_time": "2025-01-05T10:55:00",
"data": "Minimum 1 selection(s) required"
}
Invalid Option (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Invalid option: unknown_key",
"action_time": "2025-01-05T10:55:00",
"data": "Invalid option: unknown_key"
}
10. Skip Page
Purpose: Skip a preference page (only if page is skippable).
Endpoint: POST {base_url}/onboarding/pages/{pageId}/skip
Access Level: 🔒 Protected
Authentication: Bearer Token
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Page skipped",
"action_time": "2025-01-05T11:00:00",
"data": {
"saved": true,
"progress": {
"current": 2,
"total": 3,
"nextPage": 3,
"isLast": false,
"isCompleted": false
}
}
}
Error Response (Page Not Skippable):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "This page cannot be skipped",
"action_time": "2025-01-05T11:00:00",
"data": "This page cannot be skipped"
}
Language Preference Endpoint
11. Set Language Preference
Purpose: Update user's language preference (can be called anytime during onboarding).
Endpoint: POST {base_url}/onboarding/language-preference
Access Level: 🔒 Protected
Authentication: Bearer Token
Request JSON Sample:
{
"code": "sw"
}
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Language preference updated",
"action_time": "2025-01-05T11:05:00",
"data": {
"code": "sw",
"name": "Swahili",
"nativeName": "Kiswahili"
}
}
Admin: Manage Onboarding Pages
These endpoints allow admins to create, edit, reorder, and manage onboarding preference pages without code changes.
12. Get All Pages (Admin)
Purpose: Get all onboarding pages with full details for admin management.
Endpoint: GET {base_url}/onboarding/pages/manage
Access Level: 🔒 Protected (Admin/Moderator)
Authentication: Bearer Token (ROLE_ADMIN, ROLE_SUPER_ADMIN, ROLE_MODERATOR)
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Pages retrieved",
"action_time": "2025-01-05T12:00:00",
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"categoryKey": "interests",
"pageOrder": 1,
"isActive": true,
"isSkippable": false,
"minSelections": 1,
"maxSelections": 5,
"bannerImages": ["https://cdn.fursahub.com/onboarding/interests.jpg"],
"translations": {
"en": {
"title": "Your Interests",
"description": "Select what interests you"
},
"sw": {
"title": "Maslahi Yako",
"description": "Chagua mambo yanayokuvutia"
}
},
"options": [
{
"key": "jobs",
"icon": "briefcase",
"translations": {
"en": "Jobs",
"sw": "Kazi"
}
},
{
"key": "funding",
"icon": "dollar",
"translations": {
"en": "Funding",
"sw": "Ufadhili"
}
}
],
"createdAt": "2025-01-01T00:00:00",
"updatedAt": "2025-01-05T10:00:00"
}
]
}
13. Get Page by ID (Admin)
Purpose: Get single page details for editing.
Endpoint: GET {base_url}/onboarding/pages/manage/{pageId}
Access Level: 🔒 Protected (Admin/Moderator)
Path Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| pageId | UUID | Yes | Page identifier |
Success Response: Same structure as single item in Get All Pages
14. Create Page
Purpose: Create a new onboarding preference page.
Endpoint: POST {base_url}/onboarding/pages/manage
Access Level: 🔒 Protected (Admin/Moderator)
Authentication: Bearer Token
Request JSON Sample:
{
"categoryKey": "location",
"pageOrder": 4,
"isActive": true,
"isSkippable": true,
"minSelections": 1,
"maxSelections": 1,
"bannerImages": ["https://cdn.fursahub.com/onboarding/location.jpg"],
"translations": {
"en": {
"title": "Your Location",
"description": "Where are you based?"
},
"sw": {
"title": "Mahali Ulipo",
"description": "Unaishi wapi?"
}
},
"options": [
{
"key": "dar_es_salaam",
"icon": "map-pin",
"translations": {
"en": "Dar es Salaam",
"sw": "Dar es Salaam"
}
},
{
"key": "arusha",
"icon": "map-pin",
"translations": {
"en": "Arusha",
"sw": "Arusha"
}
},
{
"key": "mwanza",
"icon": "map-pin",
"translations": {
"en": "Mwanza",
"sw": "Mwanza"
}
},
{
"key": "other",
"icon": "map",
"translations": {
"en": "Other",
"sw": "Nyingine"
}
}
]
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| categoryKey | string | Yes | Unique category identifier | Lowercase, no spaces |
| pageOrder | integer | Yes | Display order (1, 2, 3...) | Min: 1 |
| isActive | boolean | No | Page is active | Default: true |
| isSkippable | boolean | No | User can skip this page | Default: false |
| minSelections | integer | No | Minimum options to select | Default: 1 |
| maxSelections | integer | No | Maximum options to select | Default: 10 |
| bannerImages | array | No | Banner image URLs | Array of URLs |
| translations | object | Yes | Title/description per language | Must include "en" |
| translations.{lang}.title | string | Yes | Page title | Max 100 chars |
| translations.{lang}.description | string | No | Page description | Max 500 chars |
| options | array | Yes | Selectable options | Min 2 options |
| options[].key | string | Yes | Unique option key | Lowercase, no spaces |
| options[].icon | string | No | Icon name | From icon library |
| options[].translations | object | Yes | Label per language | Must include "en" |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "CREATED",
"message": "Page created",
"action_time": "2025-01-05T12:05:00",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"categoryKey": "location",
"pageOrder": 4,
"isActive": true,
...
}
}
Error Responses:
Duplicate Category Key (400):
{
"success": false,
"httpStatus": "BAD_REQUEST",
"message": "Category key already exists: interests",
"action_time": "2025-01-05T12:05:00",
"data": "Category key already exists: interests"
}
15. Update Page
Purpose: Update an existing onboarding page.
Endpoint: PUT {base_url}/onboarding/pages/manage/{pageId}
Access Level: 🔒 Protected (Admin/Moderator)
Path Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| pageId | UUID | Yes | Page identifier |
Request JSON Sample: Same as Create Page
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Page updated",
"action_time": "2025-01-05T12:10:00",
"data": { ... }
}
16. Delete Page
Purpose: Delete an onboarding page (soft delete recommended - use deactivate instead).
Endpoint: DELETE {base_url}/onboarding/pages/manage/{pageId}
Access Level: 🔒 Protected (Admin/Moderator)
Path Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| pageId | UUID | Yes | Page identifier |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Page deleted",
"action_time": "2025-01-05T12:15:00",
"data": null
}
17. Activate Page
Purpose: Activate a deactivated page.
Endpoint: PATCH {base_url}/onboarding/pages/manage/{pageId}/activate
Access Level: 🔒 Protected (Admin/Moderator)
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Page activated",
"action_time": "2025-01-05T12:20:00",
"data": null
}
18. Deactivate Page
Purpose: Deactivate a page (hides from users without deleting).
Endpoint: PATCH {base_url}/onboarding/pages/manage/{pageId}/deactivate
Access Level: 🔒 Protected (Admin/Moderator)
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Page deactivated",
"action_time": "2025-01-05T12:25:00",
"data": null
}
19. Reorder Pages
Purpose: Change the display order of all pages at once.
Endpoint: PATCH {base_url}/onboarding/pages/manage/reorder
Access Level: 🔒 Protected (Admin/Moderator)
Request JSON Sample:
{
"pageIds": [
"550e8400-e29b-41d4-a716-446655440002",
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440003",
"550e8400-e29b-41d4-a716-446655440004"
]
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| pageIds | array | Yes | Page IDs in new order | Must include all active page IDs |
Success Response JSON Sample:
{
"success": true,
"httpStatus": "OK",
"message": "Pages reordered",
"action_time": "2025-01-05T12:30:00",
"data": null
}
Admin Panel Implementation Guide
Page List View
GET /onboarding/pages/manage
├── Display table with: Order, Category, Title (en), Status, Actions
├── Actions: Edit, Activate/Deactivate, Delete
├── Drag-and-drop for reorder → PATCH /onboarding/pages/manage/reorder
└── "Add New Page" button
Create/Edit Page Form
Fields:
├── Category Key (text, required, unique)
├── Page Order (number)
├── Is Skippable (checkbox)
├── Min/Max Selections (numbers)
├── Banner Images (file upload → Files API)
├── Translations (tabs for each language)
│ ├── Title (text)
│ └── Description (textarea)
└── Options (repeater)
├── Key (text, unique within page)
├── Icon (icon picker)
└── Translations (text per language)
On Save:
├── New: POST /onboarding/pages/manage
└── Edit: PUT /onboarding/pages/manage/{pageId}
Quick Actions
Activate: PATCH /onboarding/pages/manage/{pageId}/activate
Deactivate: PATCH /onboarding/pages/manage/{pageId}/deactivate
Delete: DELETE /onboarding/pages/manage/{pageId} (with confirmation)
Frontend Implementation Guide
Phone Verification Screen
1. Show phone input with country code selector
2. On submit: POST /onboarding/auth-phone/request-otp
3. Save token from response
4. Navigate to OTP input screen
5. Show countdown for resendAvailableIn
6. On OTP submit: POST /onboarding/auth-phone/verify
7. On success: Navigate based on nextStep
Preference Pages Loop
1. GET /onboarding/pages?current=true
2. If page is null → All done, navigate to profile
3. Display page with:
├── Title, description (translated)
├── Banner image
├── Options as selectable chips/cards
└── Skip button (if isSkippable)
4. On submit: POST /onboarding/pages/{pageId}/response
5. Check progress.isCompleted
├── true → Navigate to profile completion
└── false → GET /onboarding/pages?current=true (loop)
Progress Indicator
GET /onboarding/progress
├── Use percentage for progress bar
├── Show steps as dots/icons
├── Highlight current step
└── Show completed steps with checkmarks