This guide covers REST API development: route structure, request validation, response format, error handling, and middleware.
Route Structure
Routes are organized by domain under src/api/routes/. Each file exports an Express router:
// src/api/routes/user/profile.routes.ts
import { Router } from 'express';
import { csrfProtection } from '../../middleware/csrf.js';
import { validateBody } from '../../middleware/validation.js';
const router = Router();
// `authenticate` is applied by the parent router in user/index.ts
router.patch('/profile', csrfProtection, validateBody(updateProfileSchema), async (req, res, next) => {
try {
const updated = await updateProfile(req.user!.sub, req.body);
res.json({ success: true, data: updated });
} catch (error) {
next(error); // Always pass errors to next()
}
});
export { router as profileRoutes };
Route Registration
Routers are exported from src/api/routes/index.ts and mounted in src/app.ts with a versioned prefix:
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/user', userRoutes);
app.use('/api/v1/lobby', lobbyRoutes);
app.use('/api/v1/lobby/tournaments', tournamentRoutes);
app.use('/api/v1/cashier', cashierRoutes);
app.use('/api/v1/admin', adminRoutes);
/health and /metrics are also mounted at the root, and the wallet callback at /api/wallet/v1.
Existing Route Files
| File | Prefix | Auth |
|---|---|---|
auth.routes.ts | /api/v1/auth | Public (some) |
user/ | /api/v1/user | Authenticated |
user-settings.routes.ts | /api/v1/user/settings | Authenticated |
lobby.routes.ts | /api/v1/lobby | Public (table creation: Admin) |
private-games.routes.ts | /api/v1/private-games | Authenticated |
cashier.routes.ts | /api/v1/cashier | Authenticated |
tournament.routes.ts | /api/v1/lobby/tournaments | Public (some) |
account.routes.ts | /api/v1/account | Authenticated |
statistics.routes.ts | /api/v1/statistics | Authenticated |
leaderboard.routes.ts | /api/v1/leaderboard | Public |
config.routes.ts | /api/v1/config | Public (some) |
pages.routes.ts | /api/v1/pages | Optional |
ticket.routes.ts | /api/v1/tickets | Authenticated |
devices.routes.ts | /api/v1/devices | Authenticated |
app.routes.ts | /api/v1/app | Public |
agent*.routes.ts | /api/v1/agent | Agent |
analytics.routes.ts | /api/v1/analytics | Admin |
metrics.routes.ts | /metrics | Admin (some) |
debug.routes.ts | /api/v1/debug | Admin |
webhook.routes.ts | /api/v1/webhooks | Public (some) |
admin.routes.ts, admin/ | /api/v1/admin | Admin |
developer/ | /api/v1/developer | Developer key |
wallet.routes.ts | /api/wallet/v1 | Provider signature |
See the REST API reference for every route.
Request Validation
Source: src/api/middleware/validation.ts
Use Zod schemas to validate request body, query params, and URL params:
import { validateBody, validateQuery, validateParams } from '../middleware/validation.js';
import { z } from 'zod';
// Define schema
const createTableSchema = z.object({
name: z.string().min(3).max(50),
smallBlind: z.number().positive(),
bigBlind: z.number().positive(),
maxPlayers: z.number().int().min(2).max(9),
});
// Use as middleware
router.post('/', authenticate, validateBody(createTableSchema), handler);
Validated query params are stored in req.validatedQuery (typed):
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
router.get('/', validateQuery(paginationSchema), async (req, res) => {
const { page, limit } = (req as any).validatedQuery;
// ...
});
On validation failure, the middleware calls next(new ValidationError(...)) which the error handler formats as:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"bigBlind": ["Big blind must be positive"]
}
}
}
Response Format
All endpoints use a consistent response envelope:
Success
{
"success": true,
"data": { ... }
}
Success with Pagination
Shapes vary by route. Lobby lists return the fields flat:
{
"success": true,
"data": {
"items": [],
"total": 150,
"page": 1,
"limit": 20,
"totalPages": 8
}
}
Error
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "Table not found"
}
}
Validation Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"email": ["Invalid email address"],
"password": ["Password must be at least 12 characters"]
}
}
}
The ApiResponse<T> type is defined in src/models/types.ts:
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
Error Handling
Source: src/models/errors.ts, src/api/middleware/error-handler.ts
Error Classes
import { PokerError, ValidationError, NotFoundError, AuthenticationError, AuthorizationError, ConflictError } from '../../models/errors.js';
// Base class — all application errors extend this
throw new PokerError('Table is full', 'TABLE_FULL', 400);
// Specific errors
throw new NotFoundError('Table'); // 404
throw new AuthenticationError(); // 401
throw new AuthorizationError('Admin only'); // 403
throw new ConflictError('Already exists'); // 409
throw new ValidationError('Bad input', details); // 400
Service Error Pattern
// In a service
async function joinTable(tableId: string, userId: string) {
const table = await prisma.table.findUnique({ where: { id: tableId } });
if (!table) {
throw new NotFoundError('Table');
}
if (table.currentPlayers >= table.maxPlayers) {
throw new PokerError('Table is full', 'TABLE_FULL', 400);
}
// ...
}
Error Handler Middleware
Source: src/api/middleware/error-handler.ts
The global error handler is registered last in app.ts:
app.use(errorHandler);
Behaviour:
PokerError(4xx): logged as warning, returned to clientPokerError(5xx) or unknown: logged as error, sent to Sentry if configured- In production, 500 errors return a generic message instead of the actual error
Middleware
Authentication
Source: src/api/middleware/auth.ts
import { authenticate, requireAdmin, requireSuperAdmin, requireFeature } from '../middleware/auth.js';
// Require any authenticated user
router.get('/profile', authenticate, handler);
// Require admin permission level (a1), plus a feature permission
router.delete('/users/:id', authenticate, requireAdmin, requireFeature('users:delete'), handler);
// Require super admin (a)
router.post('/upload', authenticate, requireAdmin, requireSuperAdmin, handler);
The authenticate middleware:
- Reads the HttpOnly
access_tokencookie, then falls back to a Bearer token in theAuthorizationheader - Verifies JWT signature using
JWT_ACCESS_SECRET - Attaches decoded payload to
req.user({ sub, username, email, permissions, ... }) - Returns 401 if token is missing or invalid
CSRF Protection
Source: src/api/middleware/csrf.ts
Routes using csrfProtection require a CSRF token on POST, PUT, PATCH and DELETE, using the double-submit cookie pattern. Clients get the token from GET /api/v1/auth/csrf-token and send it in the X-CSRF-Token header. Requests authenticated by a verified Authorization header are exempt.
Rate Limiting
Source: src/config/rate-limit.ts
Rate limiting is applied per-endpoint with express-rate-limit. Authenticated limiters key by user, so apply them after authenticate. Responses include RateLimit-* headers.
License Middleware
Source: src/api/middleware/license.middleware.ts
Blocks game-related mutation endpoints, such as table creation and private-table joins, while the license is invalid or not_configured. Read endpoints, cashier and withdrawal, admin and health endpoints pass through.
Adding a New Route
- Create
src/api/routes/myfeature.routes.ts:
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { csrfProtection } from '../middleware/csrf.js';
import { validateBody } from '../middleware/validation.js';
import { z } from 'zod';
const router = Router();
const createSchema = z.object({
name: z.string().min(1),
});
router.post('/', authenticate, csrfProtection, validateBody(createSchema), async (req, res, next) => {
try {
// ... service call
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
});
export { router as myFeatureRoutes };
- Export it from
src/api/routes/index.tsand mount it insrc/app.ts:
// src/api/routes/index.ts
export { myFeatureRoutes } from './myfeature.routes.js';
// src/app.ts
app.use('/api/v1/myfeature', myFeatureRoutes);
- Write tests in
src/api/routes/myfeature.routes.test.ts(see Testing).
Admin Routes
Admin routes are under src/api/routes/admin/ and are mounted in admin/index.ts, which applies authenticate, requireAdmin (level a1 or higher), admin rate limits and csrfProtection to every child router. Add requireFeature(...) to each route. Admin routes follow the same patterns as user routes.
Developer Integration API
Source: src/api/routes/developer/
The Developer Integration API allows external systems to provision users, manage balances and generate login codes using signed API key requests. It uses its own auth middleware (developer-auth.ts) instead of user JWTs. See Integration API for details.
Back to Developer Guide