Express 5 REST API for BrandBlitz. Handles authentication, game sessions, scoring, leaderboards, Stellar webhooks, and async payout dispatch via BullMQ.
- Overview
- Directory Structure
- Getting Started
- Environment Variables
- API Reference
- Middleware
- Services
- Database
- Queue & Worker
- Error Handling
- Building & Running
The API runs on Express 5 — async errors thrown inside route handlers automatically propagate to the global error handler without try/catch wrappers. It is stateless (all game state in PostgreSQL + Redis) and designed to run behind an Nginx reverse proxy.
The worker (src/worker.ts) is a separate entry point that runs the BullMQ payout processor. It uses the same Docker image but is started with a different command. It has no HTTP server.
apps/api/
├── Dockerfile
├── .dockerignore
├── package.json
├── tsconfig.json
├── tsup.config.ts # Build config: entry points, CJS output
└── src/
├── index.ts # Express app entry + graceful shutdown
├── worker.ts # BullMQ worker entry (no HTTP)
├── routes/
│ ├── index.ts # registerRoutes(app) — mounts all routers
│ ├── auth.ts # POST /auth/google/callback, GET /auth/me
│ ├── brands.ts # CRUD brand kits + challenge creation
│ ├── challenges.ts # List/get challenges + leaderboard
│ ├── sessions.ts # Warmup, answer, scoring
│ ├── upload.ts # Presigned URL generation + verification
│ ├── leaderboard.ts # Global + per-challenge leaderboards
│ ├── users.ts # Profile, wallet, phone verification
│ └── webhooks.ts # Stellar deposit webhook
├── middleware/
│ ├── authenticate.ts # JWT validation; adds req.user
│ ├── rate-limit.ts # Redis-backed rate limiters
│ ├── anti-cheat.ts # Reaction time + device fingerprint checks
│ └── error.ts # Global Express 5 error handler
├── services/
│ ├── scoring.ts # Round score + payout share calculation
│ ├── questions.ts # Auto-generate 3-question sets from brand kit
│ ├── payout.ts # Payout orchestration; enqueues BullMQ jobs
│ └── phone.ts # Twilio Verify wrapper
├── queues/
│ ├── payout.queue.ts # BullMQ Queue definition
│ └── processors/
│ └── payout.processor.ts # BullMQ Worker + job handler
├── db/
│ ├── index.ts # pg Pool + typed query<T>() helper
│ └── queries/
│ ├── users.ts # findUserByEmail, upsertUser, etc.
│ ├── brands.ts # createBrand, getBrandsByOwner, etc.
│ ├── challenges.ts # createChallenge, getActiveChallenges, etc.
│ ├── sessions.ts # createSession, recordRoundScore, etc.
│ └── payouts.ts # createPayout, updatePayoutStatus, etc.
└── lib/
├── redis.ts # ioredis client (maxRetriesPerRequest: null)
└── logger.ts # winston: JSON in prod, colorized in dev
# From the monorepo root
pnpm install
# Copy and configure env
cp ../../.env.example .env
# Start infrastructure (Postgres + Redis + MinIO)
docker compose up postgres redis minio minio-setup
# Run the API in dev mode (tsx watch)
pnpm --filter @brandblitz/api dev
# Run the worker in dev mode (separate terminal)
pnpm --filter @brandblitz/api dev:workerThe API listens on PORT (default 3001). In the full Docker stack, Nginx proxies /api/* → http://api:3001/.
If you are applying schema changes locally, run pnpm --filter @brandblitz/api migrate. The dry-run form is pnpm --filter @brandblitz/api migrate:dryrun.
| Variable | Required | Description |
|---|---|---|
PORT |
No | HTTP port (default: 3001) |
DATABASE_URL |
Yes | PostgreSQL connection string |
REDIS_URL |
Yes | Redis connection string |
JWT_SECRET |
Yes | HMAC secret for signing JWTs |
GOOGLE_CLIENT_ID |
Yes | Google OAuth app client ID |
GOOGLE_CLIENT_SECRET |
Yes | Google OAuth app client secret |
STELLAR_NETWORK |
Yes | testnet or public |
STELLAR_HORIZON_URL |
Yes | Horizon base URL |
STELLAR_RPC_URL |
Yes | Soroban RPC URL |
STELLAR_HOT_WALLET_SECRET |
Yes | Secret key for payout wallet |
USDC_ISSUER |
Yes | USDC asset issuer address |
S3_ENDPOINT |
Yes | S3-compatible endpoint |
S3_REGION |
Yes | Region (e.g. us-east-1) |
S3_ACCESS_KEY_ID |
Yes | S3 access key |
S3_SECRET_ACCESS_KEY |
Yes | S3 secret key |
S3_BUCKET |
Yes | Bucket name |
S3_PUBLIC_URL |
Yes | Base URL for public asset access |
S3_FORCE_PATH_STYLE |
No | "true" for MinIO (dev) |
TWILIO_ACCOUNT_SID |
No | Twilio account SID |
TWILIO_AUTH_TOKEN |
No | Twilio auth token |
TWILIO_VERIFY_SERVICE_SID |
No | Twilio Verify service SID |
WEBHOOK_SECRET |
Yes | X-Webhook-Secret header value |
NEXTAUTH_URL |
Yes | Frontend base URL (for CORS) |
All routes are mounted under the base path the proxy uses (/api in Docker). Direct calls during development hit http://localhost:3001 without the /api prefix.
Authentication uses Bearer JWTs in the Authorization header. The JWT payload is { sub: userId, email }.
Called by next-auth after Google OAuth. Creates or updates the user in the database, issues an API JWT.
Body:
{
"googleId": "1234567890",
"email": "user@example.com",
"name": "Jane Smith",
"picture": "https://..."
}Response 200:
{
"token": "<jwt>",
"user": { "id": "...", "email": "...", "displayName": "..." }
}Returns the authenticated user's profile.
Headers: Authorization: Bearer <token>
Response 200:
{
"user": {
"id": "uuid",
"email": "...",
"displayName": "...",
"stellarAddress": null,
"phoneVerified": false,
"league": "bronze"
}
}List active challenges. Supports ?limit=N&offset=N&brandId=uuid.
Response 200:
{
"challenges": [
{
"id": "uuid",
"brandName": "Acme Corp",
"logoUrl": "https://...",
"primaryColor": "#6366f1",
"poolAmountUsdc": "100.0000000",
"status": "active",
"endsAt": "2026-04-04T12:00:00Z",
"participantCount": 42
}
]
}Returns challenge details plus the 3 questions for this challenge. Correct answers are withheld — only questionText, optionA/B/C/D, and round are returned.
Response 200:
{
"challenge": { "id": "...", "brandName": "...", ... },
"questions": [
{
"round": 1,
"questionText": "What is Acme Corp's tagline?",
"optionA": "Just Do It",
"optionB": "We Build Tomorrow",
"optionC": "Think Different",
"optionD": "Always Coca-Cola"
}
]
}Returns the top scores for a specific challenge.
Response 200:
{
"leaderboard": [
{
"rank": 1,
"userId": "...",
"displayName": "Jane",
"totalScore": 420,
"totalEarned": "15.3200000"
}
]
}All session routes require authentication.
Called when the user begins the warmup phase. Creates a session row.
Body: { "deviceId": "fingerprintjs-visitor-id" } (optional)
Response 200: { "sessionId": "uuid" }
Called after the user has viewed warmup content. Server enforces a minimum of 20 seconds since warmup-start. On success, returns a short-lived challengeToken.
Response 200: { "challengeToken": "<signed-token>" }
Response 403: If called before 20 seconds have elapsed.
Transitions session to active. Requires the challengeToken from warmup-complete.
Body: { "challengeToken": "..." }
Response 200: { "started": true }
Submit an answer for round 1, 2, or 3. Round must be submitted in order. The server validates the answer and returns the score immediately.
Body:
{
"selectedOption": "B",
"reactionTimeMs": 4200
}Response 200:
{
"correct": true,
"score": 137,
"totalScore": 137
}The correct_option is never included in the response.
All routes require authentication. Brand creation routes additionally check role === 'brand' or auto-promote the user.
Returns all brand kits owned by the authenticated user.
Returns a single brand kit. Owners get full details; public access gets limited fields.
Creates a new brand kit. Triggers background image optimisation (WebP, resized) via packages/storage.
Body:
{
"name": "Acme Corp",
"tagline": "We Build Tomorrow",
"description": "Long brand story...",
"primaryColor": "#6366f1",
"logoKey": "brand-logo/uuid.webp",
"productImageKeys": ["product-image/uuid.webp"]
}Response 201: { "brand": { ... } }
Creates a challenge for a brand kit, generates 3 quiz questions from the brand's content, and returns deposit instructions.
Body:
{
"poolAmountUsdc": "100.00",
"durationHours": 72
}Response 201:
{
"challenge": { "id": "...", "status": "pending_deposit" },
"depositAddress": "G...",
"memo": "BLITZ-A1B2C3",
"instructions": "Send exactly 100.00 USDC to G... with memo BLITZ-A1B2C3"
}Returns a presigned S3 PUT URL valid for 60 seconds. Files are uploaded directly from the browser to MinIO/S3 — they never pass through Express.
Body:
{
"filename": "logo.png",
"contentType": "image/png",
"uploadType": "brand-logo"
}Response 200:
{
"presignedUrl": "https://...",
"key": "brand-logo/uuid.png",
"publicUrl": "https://..."
}Calls HeadObject to confirm the file was actually uploaded before saving the key to the database.
Body: { "key": "brand-logo/uuid.png" }
Response 200: { "verified": true, "size": 45312 }
All-time top 100 players across all challenges. Cached in Redis for 5 minutes.
Live leaderboard for a specific challenge. No cache — reflects real-time scores.
Full authenticated user profile including stats and recent sessions.
Set or update the user's Stellar address.
Body: { "stellarAddress": "G..." }
Send a Twilio Verify OTP to the user's phone number.
Body: { "phoneNumber": "+15551234567" }
Verify the OTP. Marks phone_verified = true on success.
Body: { "phoneNumber": "+15551234567", "code": "123456" }
Public profile — display name, stats, recent sessions. No auth required.
Called by a Stellar event listener when a USDC deposit is detected. Protected by X-Webhook-Secret header.
Body:
{
"memo": "BLITZ-A1B2C3",
"amount": "100.0000000",
"from": "G...",
"txHash": "..."
}On receipt: validates memo against challenges table → transitions status to active.
Validates Authorization: Bearer <jwt> on every protected route. Adds req.user: { sub, email } to the request. An optionalAuth variant is available for public routes that behave differently when authenticated.
compression({ threshold: 1024 }) is mounted early in src/index.ts so JSON responses larger than 1 KB are compressed before they leave the API process. Nginx in this repo does not add a second compression layer, so Vary: Accept-Encoding is preserved without double-compressing the payload.
Redis-backed rate limiters using express-rate-limit:
| Limiter | Limit | Window | Applied to |
|---|---|---|---|
apiLimiter |
100 requests | 15 min | All /api/* |
authLimiter |
10 requests | 15 min | /auth/* |
challengeStartLimiter |
5 requests | 1 hour | /sessions/*/warmup-start |
uploadLimiter |
20 requests | 1 hour | /upload/* |
validateReactionTime— Rejects answers withreactionTimeMs < 150(physically impossible) or> 30000(timeout). Out-of-range values are clamped with a fraud flag recorded.enforceOneSessionPerChallenge— Redis SETNX lock prevents duplicate active sessions per user+challenge.validateDeviceFingerprint— Checks the FingerprintJSvisitorIdagainst a Redis set of known device IDs per challenge. Flags accounts sharing a device.
Global Express 5 error handler. Handles known AppError types with clean JSON responses and falls back to 500 for unexpected errors. Logs full stack traces in development.
{
"error": "Warmup period not complete",
"code": "WARMUP_INCOMPLETE",
"statusCode": 403
}calculateRoundScore(correct, reactionTimeMs)— Returns 0 for incorrect, 100–150 for correct (100 base + 0–50 speed bonus).calculatePayoutShare(userScore, totalScore, prizePool)— Proportional share of the prize pool.rankWinners(sessions)— Sorts bytotalScore DESC, assigns integer ranks, handles ties.
Generates 3 rounds of MCQ questions from a brand's kit:
- Round 1 — Tagline recognition (4 options: correct + 3 distractors from a pool)
- Round 2 — USP / brand story match
- Round 3 — Product image identification
Questions are stored in challenge_questions and never change after creation.
enqueuePayout(challengeId)— Adds a job to the BullMQpayoutqueue after a challenge'sends_atpasses.processPayout(challengeId)— Full orchestration:- Load all completed (non-flagged) sessions
- Rank and calculate proportional shares
- Filter to users with a Stellar address
- Call
submitBatchPayoutfrompackages/stellar - Write
tx_hashand update payout statuses - Update user
total_earned_usdc
Thin wrapper around Twilio Verify API:
sendVerificationCode(to)— Sends SMS OTPcheckVerificationCode(to, code)— Returns"approved"or"pending"requirePhoneVerified(req, res, next)— Middleware that blocks unverified users
Raw pg pool with a typed query<T>(text, values) helper. No ORM.
max: 20 connections. Slow query warning logged when a query exceeds 500ms.
Each file in src/db/queries/ exports named functions that take typed parameters and return typed results. Example:
// src/db/queries/users.ts
export async function findUserByEmail(email: string): Promise<User | null>
export async function upsertUser(data: UpsertUserData): Promise<User>
export async function updateUserWallet(userId: string, stellarAddress: string): Promise<void>See ../../init.sql for the bootstrap entrypoint. The canonical schema snapshot lives in ./migrations/00000-initial.sql, and apps/api/migrations/ contains forward migrations plus optional rollback files.
BullMQ Queue named "payout" connected to Redis. Jobs are added with a delay calculated from challenge.ends_at.
BullMQ Worker with concurrency: 2. Calls processPayout(challengeId) for each job. Retries up to 3 times with exponential back-off on failure. Failed jobs remain in the BullMQ failed set for inspection.
# Dev (tsx watch)
pnpm --filter @brandblitz/api dev:worker
# Docker (same image, different CMD)
docker compose up workerThe worker process is intentionally separated from the API so that a slow payout job never blocks incoming HTTP requests.
Express 5 automatically catches errors thrown in async route handlers. Create typed errors with the createError helper:
import { createError } from "@/middleware/error";
// In any route handler:
throw createError("Challenge not found", 404, "CHALLENGE_NOT_FOUND");Standard HTTP errors (404 for unknown routes, 405 for wrong methods) are handled automatically by Express 5.
# Development (TypeScript, watch mode)
pnpm --filter @brandblitz/api dev
# Build (tsup → dist/)
pnpm --filter @brandblitz/api build
# Start production build
node dist/index.js
# Start worker (production)
node dist/worker.js
# Type check
pnpm --filter @brandblitz/api type-check
# Apply pending migrations
pnpm --filter @brandblitz/api migrate
# Verify there are no pending migrations
pnpm --filter @brandblitz/api migrate:dryrunThe Dockerfile is a 5-stage multi-stage build:
| Stage | Purpose |
|---|---|
deps |
Install all dependencies (cached layer) |
dev |
Dev dependencies included; used with bind mounts |
builder |
Compile TypeScript with tsup |
prod-deps |
Production-only dependencies |
runner |
Minimal final image; non-root user; SIGTERM handler |
Build from the monorepo root (required — the build context includes packages/):
docker build -f apps/api/Dockerfile -t brandblitz-api .