Skip to content

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

typescript
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

MethodPathAuthDescription
GET/healthHealth check
POST/auth/otp/sendSend OTP to phone
POST/auth/otp/verifyVerify OTP, get tokens
POST/auth/refreshRefresh access token
POST/auth/logoutRevoke refresh token
GET/users/meGet current user profile
PATCH/users/meUpdate profile
GET/users/@:handlePublic profile by handle
GET/users/handle/checkCheck handle availability

Health Check

GET /health

Auth: None

Response:

json
{
  "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:

typescript
const sendOtpSchema = z.object({
  phone: z.string().regex(/^\+\d{10,15}$/, 'Invalid phone format'),
});

Request:

json
{
  "phone": "+26878422613"
}

Response (200):

json
{
  "success": true,
  "message": "OTP sent",
  "expiresIn": 300
}

Errors:

  • 400 — Invalid phone format

Implementation:

typescript
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:

typescript
const verifyOtpSchema = z.object({
  phone: z.string().regex(/^\+\d{10,15}$/),
  otp: z.string().length(6),
});

Request:

json
{
  "phone": "+26878422613",
  "otp": "123456"
}

Response (200):

json
{
  "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):

json
{
  "success": false,
  "error": "Invalid or expired OTP"
}

Implementation:

typescript
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:

  1. Validate OTP
  2. Find existing user by phone OR create new user
  3. Generate JWT access token + refresh token
  4. Return user profile and tokens

POST /auth/refresh

Refresh an expired access token using refresh token.

Auth: None

Request Schema:

typescript
const refreshSchema = z.object({
  refreshToken: z.string(),
});

Request:

json
{
  "refreshToken": "a1b2c3d4e5f6..."
}

Response (200):

json
{
  "success": true,
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "new-refresh-token...",
  "expiresIn": 900
}

Response (401):

json
{
  "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:

json
{
  "refreshToken": "a1b2c3d4e5f6..."
}

Response (200):

json
{
  "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):

json
{
  "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):

json
{
  "success": false,
  "error": "Missing or invalid authorization header"
}

Response (404):

json
{
  "success": false,
  "error": "User not found"
}

PATCH /users/me

Update current user's profile.

Auth: Required (Bearer token)

Request Schema:

typescript
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:

json
{
  "name": "Laslie Georges Jr.",
  "handle": "laslie"
}

Response (200):

json
{
  "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):

json
{
  "success": false,
  "error": "This handle is already taken"
}

Implementation:

typescript
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):

json
{
  "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):

json
{
  "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:

json
{
  "success": true,
  "handle": "laslie",
  "available": true
}

Response (200) — Not Available:

json
{
  "success": true,
  "handle": "admin",
  "available": false,
  "reason": "This handle is reserved"
}

Response (400):

json
{
  "success": false,
  "error": "Handle required"
}

Response Patterns

Success Response

json
{
  "success": true,
  // ... data
}

Error Response

json
{
  "success": false,
  "error": "Human-readable error message"
}

Validation Error Response

json
{
  "success": false,
  "error": "Validation failed",
  "details": [
    {
      "field": "phone",
      "message": "Invalid phone format"
    }
  ]
}

Error Codes

HTTP StatusMeaning
200Success
400Bad Request (validation failed)
401Unauthorized (missing/invalid token)
404Not Found
409Conflict (duplicate record)
500Internal Server Error

One chat. Everything done.