YeboID Routes — Complete API Reference
This document provides the complete route map for YeboID API with methods, paths, authentication requirements, request/response schemas, and implementation details.
Base Configuration
Entry Point: api/src/index.ts
import express from 'express';
import cors from 'cors';
import { authRouter } from './routes/auth.js';
import { usersRouter } from './routes/users.js';
import { errorHandler } from './middleware/error.js';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/auth', authRouter);
app.use('/users', usersRouter);
// Error handler
app.use(errorHandler);Route Summary Table
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | ❌ | Health check |
| POST | /auth/otp/send | ❌ | Send OTP to phone |
| POST | /auth/otp/verify | ❌ | Verify OTP, get tokens |
| POST | /auth/refresh | ❌ | Refresh access token |
| POST | /auth/logout | ❌ | Revoke refresh token |
| GET | /users/me | ✅ | Get current user profile |
| PATCH | /users/me | ✅ | Update profile |
| GET | /users/@:handle | ❌ | Public profile by handle |
| GET | /users/handle/check | ❌ | Check handle availability |
Health Check
GET /health
Auth: None
Response:
{
"status": "ok",
"service": "yeboid",
"version": "0.1.0"
}Authentication Routes
File: api/src/routes/auth.ts
POST /auth/otp/send
Send OTP to a phone number for verification.
Auth: None
Request Schema:
const sendOtpSchema = z.object({
phone: z.string().regex(/^\+\d{10,15}$/, 'Invalid phone format'),
});Request:
{
"phone": "+26878422613"
}Response (200):
{
"success": true,
"message": "OTP sent",
"expiresIn": 300
}Errors:
400— Invalid phone format
Implementation:
authRouter.post('/otp/send', async (req, res, next) => {
try {
const { phone } = sendOtpSchema.parse(req.body);
const result = await otpService.sendOtp(phone);
res.json({
success: true,
message: 'OTP sent',
expiresIn: result.expiresIn
});
} catch (error) {
next(error);
}
});POST /auth/otp/verify
Verify OTP and authenticate. Creates user if not exists.
Auth: None
Request Schema:
const verifyOtpSchema = z.object({
phone: z.string().regex(/^\+\d{10,15}$/),
otp: z.string().length(6),
});Request:
{
"phone": "+26878422613",
"otp": "123456"
}Response (200):
{
"success": true,
"user": {
"id": "uuid-string",
"phone": "+26878422613",
"handle": "laslie",
"name": "Laslie Georges Jr.",
"avatar": "https://...",
"kycStatus": "NONE"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "a1b2c3d4e5f6...",
"expiresIn": 900
}Response (401):
{
"success": false,
"error": "Invalid or expired OTP"
}Implementation:
authRouter.post('/otp/verify', async (req, res, next) => {
try {
const { phone, otp } = verifyOtpSchema.parse(req.body);
// Verify OTP
const isValid = await otpService.verifyOtp(phone, otp);
if (!isValid) {
return res.status(401).json({
success: false,
error: 'Invalid or expired OTP'
});
}
// Find or create user
let user = await prisma.user.findUnique({ where: { phone } });
if (!user) {
user = await prisma.user.create({
data: { phone }
});
}
// Generate tokens
const tokens = await tokenService.generateTokens(user.id);
res.json({
success: true,
user: {
id: user.id,
phone: user.phone,
handle: user.handle,
name: user.name,
avatar: user.avatar,
kycStatus: user.kycStatus,
},
...tokens
});
} catch (error) {
next(error);
}
});Flow:
- Validate OTP
- Find existing user by phone OR create new user
- Generate JWT access token + refresh token
- Return user profile and tokens
POST /auth/refresh
Refresh an expired access token using refresh token.
Auth: None
Request Schema:
const refreshSchema = z.object({
refreshToken: z.string(),
});Request:
{
"refreshToken": "a1b2c3d4e5f6..."
}Response (200):
{
"success": true,
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "new-refresh-token...",
"expiresIn": 900
}Response (401):
{
"success": false,
"error": "Invalid or expired refresh token"
}Notes:
- Refresh token is rotated — old one becomes invalid
- If same refresh token is used twice, both attempts fail (security)
POST /auth/logout
Revoke refresh token (logout current session).
Auth: None (but requires valid refresh token in body)
Request:
{
"refreshToken": "a1b2c3d4e5f6..."
}Response (200):
{
"success": true,
"message": "Logged out"
}User Routes
File: api/src/routes/users.ts
GET /users/me
Get current authenticated user's profile.
Auth: Required (Bearer token)
Headers:
Authorization: Bearer <access_token>Response (200):
{
"success": true,
"user": {
"id": "uuid-string",
"phone": "+26878422613",
"handle": "laslie",
"name": "Laslie Georges Jr.",
"email": "laslie@example.com",
"avatar": "https://...",
"kycStatus": "VERIFIED",
"createdAt": "2026-03-18T10:00:00.000Z"
}
}Response (401):
{
"success": false,
"error": "Missing or invalid authorization header"
}Response (404):
{
"success": false,
"error": "User not found"
}PATCH /users/me
Update current user's profile.
Auth: Required (Bearer token)
Request Schema:
const updateProfileSchema = z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
avatar: z.string().url().optional(),
handle: z.string().min(3).max(30).regex(/^[a-z0-9_]+$/).optional(),
});Request:
{
"name": "Laslie Georges Jr.",
"handle": "laslie"
}Response (200):
{
"success": true,
"user": {
"id": "uuid-string",
"phone": "+26878422613",
"handle": "laslie",
"name": "Laslie Georges Jr.",
"email": null,
"avatar": null,
"kycStatus": "NONE",
"createdAt": "2026-03-18T10:00:00.000Z"
}
}Response (400):
{
"success": false,
"error": "This handle is already taken"
}Implementation:
usersRouter.patch('/me', authMiddleware, async (req, res, next) => {
try {
const data = updateProfileSchema.parse(req.body);
// If updating handle, validate it
if (data.handle) {
const validation = await handleService.validateHandle(data.handle, req.userId);
if (!validation.available) {
return res.status(400).json({
success: false,
error: validation.reason
});
}
}
const user = await prisma.user.update({
where: { id: req.userId },
data,
select: { /* fields */ }
});
res.json({ success: true, user });
} catch (error) {
next(error);
}
});GET /users/@:handle
Get public profile by handle.
Auth: None
URL Example: GET /users/@laslie
Response (200):
{
"success": true,
"user": {
"id": "uuid-string",
"handle": "laslie",
"name": "Laslie Georges Jr.",
"avatar": "https://...",
"kycStatus": "VERIFIED",
"createdAt": "2026-03-18T10:00:00.000Z"
}
}Response (404):
{
"success": false,
"error": "User not found"
}Notes:
- Returns PUBLIC fields only (no phone, no email)
- Handle is case-insensitive (normalized to lowercase)
GET /users/handle/check
Check if a handle is available.
Auth: None
Query Params:
handle(required): Handle to check
URL Example: GET /users/handle/check?handle=laslie
Response (200) — Available:
{
"success": true,
"handle": "laslie",
"available": true
}Response (200) — Not Available:
{
"success": true,
"handle": "admin",
"available": false,
"reason": "This handle is reserved"
}Response (400):
{
"success": false,
"error": "Handle required"
}Response Patterns
Success Response
{
"success": true,
// ... data
}Error Response
{
"success": false,
"error": "Human-readable error message"
}Validation Error Response
{
"success": false,
"error": "Validation failed",
"details": [
{
"field": "phone",
"message": "Invalid phone format"
}
]
}Error Codes
| HTTP Status | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad Request (validation failed) |
| 401 | Unauthorized (missing/invalid token) |
| 404 | Not Found |
| 409 | Conflict (duplicate record) |
| 500 | Internal Server Error |