API Development

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

FilePrefixAuth
auth.routes.ts/api/v1/authPublic (some)
user//api/v1/userAuthenticated
user-settings.routes.ts/api/v1/user/settingsAuthenticated
lobby.routes.ts/api/v1/lobbyPublic (table creation: Admin)
private-games.routes.ts/api/v1/private-gamesAuthenticated
cashier.routes.ts/api/v1/cashierAuthenticated
tournament.routes.ts/api/v1/lobby/tournamentsPublic (some)
account.routes.ts/api/v1/accountAuthenticated
statistics.routes.ts/api/v1/statisticsAuthenticated
leaderboard.routes.ts/api/v1/leaderboardPublic
config.routes.ts/api/v1/configPublic (some)
pages.routes.ts/api/v1/pagesOptional
ticket.routes.ts/api/v1/ticketsAuthenticated
devices.routes.ts/api/v1/devicesAuthenticated
app.routes.ts/api/v1/appPublic
agent*.routes.ts/api/v1/agentAgent
analytics.routes.ts/api/v1/analyticsAdmin
metrics.routes.ts/metricsAdmin (some)
debug.routes.ts/api/v1/debugAdmin
webhook.routes.ts/api/v1/webhooksPublic (some)
admin.routes.ts, admin//api/v1/adminAdmin
developer//api/v1/developerDeveloper key
wallet.routes.ts/api/wallet/v1Provider 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 client
  • PokerError (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:

  1. Reads the HttpOnly access_token cookie, then falls back to a Bearer token in the Authorization header
  2. Verifies JWT signature using JWT_ACCESS_SECRET
  3. Attaches decoded payload to req.user ({ sub, username, email, permissions, ... })
  4. 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

  1. 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 };
  1. Export it from src/api/routes/index.ts and mount it in src/app.ts:
// src/api/routes/index.ts
export { myFeatureRoutes } from './myfeature.routes.js';

// src/app.ts
app.use('/api/v1/myfeature', myFeatureRoutes);
  1. 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

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.