- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages - Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification) - Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores - Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready - Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy - Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure - i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next - Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils - CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks) - Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md - Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts - Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
10 KiB
InvestPlay — API Documentation
Base URL Structure
| Environment | Base URL |
|---|---|
| Local development | http://localhost:3001 |
| Production | https://api.investplay.app |
| Staging | https://api.staging.investplay.app |
Authentication
JWT Bearer Token
All authenticated requests require an Authorization header:
Authorization: Bearer <accessToken>
Access tokens are RS256-signed JWTs issued by the auth service. They are short-lived (15 minutes in production, 7 days in development).
Token Refresh
When the access token expires, use the refresh token to obtain a new pair:
POST /auth/refresh
Content-Type: application/json
Cookie: refreshToken=<httpOnly-cookie>
Response 200:
{
"accessToken": "eyJhbG...",
"expiresIn": 900
}
The refresh token is stored in an httpOnly, Secure, SameSite=Strict cookie set at login. If the refresh token is also expired, the user must re-authenticate.
Token Errors
| Status | Error Code | Description |
|---|---|---|
| 401 | TOKEN_EXPIRED |
Access token has expired; refresh required |
| 401 | TOKEN_INVALID |
Token signature verification failed |
| 401 | TOKEN_MISSING |
No Authorization header provided |
| 403 | INSUFFICIENT_ROLE |
User lacks required role for this operation |
GraphQL Endpoint
URL: POST /graphql
Content-Type: application/json
Example Query
query GetLessonProgress {
me {
id
displayName
xp
level
progress {
completedLessons
totalLessons
currentStreak
}
}
lesson(id: "lesson_abc123") {
title
estimatedMinutes
blocks {
... on HeroBlock {
headline
subtitle
imageUrl
}
... on TextBlock {
body
}
... on QuizBlock {
question
options
correctAnswer
}
}
}
}
Example Mutation
mutation SubmitQuiz {
submitQuiz(
lessonId: "lesson_abc123",
answers: [
{ questionId: "q1", selectedOption: 2 },
{ questionId: "q2", selectedOption: 0 }
]
) {
... on QuizResultSuccess {
score
totalQuestions
correctAnswers
xpEarned
passed
}
... on QuizResultError {
code
message
}
}
}
Response Format
{
"data": { ... },
"errors": [
{
"message": "Validation failed",
"extensions": {
"code": "VALIDATION_ERROR",
"field": "answers",
"details": "Each answer must include a valid questionId"
}
}
]
}
Error Codes
| Extension Code | HTTP Status | Description |
|---|---|---|
UNAUTHENTICATED |
401 | Not authenticated |
FORBIDDEN |
403 | Insufficient permissions |
VALIDATION_ERROR |
400 | Input validation failed |
NOT_FOUND |
404 | Requested resource not found |
RATE_LIMITED |
429 | Too many requests |
AI_QUOTA_EXCEEDED |
429 | AI token budget exhausted for user/tenant |
INTERNAL_ERROR |
500 | Unexpected server error |
REST Endpoints
Health Check
GET /health
Response 200:
{
"status": "ok",
"uptime": 3600,
"timestamp": "2026-06-12T16:00:00Z",
"services": {
"database": "healthy",
"redis": "healthy",
"storage": "healthy",
"cms": "healthy"
}
}
Used by Docker health checks and monitoring.
Auth Callback (Clerk)
POST /auth/callback
Content-Type: application/json
Request:
{
"code": "oauth_code_from_clerk",
"redirectUri": "https://app.investplay.app/auth/callback"
}
Response 200:
{
"accessToken": "eyJhbG...",
"refreshToken": "eyJhbG...",
"user": {
"id": "user_abc",
"email": "student@example.com",
"displayName": "Alex",
"locale": "en",
"onboardingComplete": false
}
}
Auth Logout
POST /auth/logout
Response 200:
{
"message": "Logged out successfully"
}
Clears refresh token cookie and invalidates the session.
Stripe Webhook
POST /webhooks/stripe
Content-Type: application/json
Stripe-Signature: <webhook signature>
Handles: checkout.session.completed, invoice.paid, customer.subscription.updated
Response 200: { "received": true }
Clerk Webhook
POST /webhooks/clerk
Content-Type: application/json
Svix-Signature: <webhook signature>
Handles: user.created, user.updated, user.deleted, session.created
Response 200: { "received": true }
Resend Webhook
POST /webhooks/resend
Content-Type: application/json
Handles: email.delivered, email.bounced, email.complained
Response 200: { "received": true }
WebSocket Endpoints
Simulation Tick Stream
WebSocket: wss://api.investplay.app/simulations
Auth: { token: "<accessToken>" }
Events:
| Event | Direction | Payload | Description |
|---|---|---|---|
join |
Client -> Server | { simulationId: string } |
Join a simulation room |
leave |
Client -> Server | { simulationId: string } |
Leave a simulation room |
tick |
Server -> Client | { timestamp, price, volume, indicator } |
Market price tick |
status |
Server -> Client | { status, speed, elapsed } |
Simulation status change |
complete |
Server -> Client | { finalPnl, totalTrades, roi } |
Simulation ended |
AI Coach Stream
WebSocket: wss://api.investplay.app/ai-coach
Auth: { token: "<accessToken>" }
Events:
| Event | Direction | Payload | Description |
|---|---|---|---|
message |
Client -> Server | { content, contextId?, lessonId? } |
Send chat message |
response |
Server -> Client | { content, done, tokensUsed } |
Streaming AI response chunk |
error |
Server -> Client | { code, message } |
Error (quota exceeded, etc.) |
Classroom Broadcast
WebSocket: wss://api.investplay.app/classroom
Auth: { token: "<accessToken>" }
Events:
| Event | Direction | Payload | Description |
|---|---|---|---|
join |
Client -> Server | { classroomId: string } |
Join classroom room |
broadcast |
Teacher -> Server | { type, payload } |
Teacher broadcasts event |
event |
Server -> Student | { type, payload, timestamp } |
Event delivered to students |
Rate Limiting
Limits
| Endpoint | Window | Limit | Scope |
|---|---|---|---|
/graphql |
60 seconds | 100 requests | Per IP address |
/auth/* |
60 seconds | 20 requests | Per IP address |
/webhooks/* |
60 seconds | 50 requests | Per IP address |
| AI Chat | 1 day | 20 messages (default) | Per user |
| AI Tokens | 1 month | 500,000 tokens (default) | Per tenant |
Rate Limit Headers
All responses include rate limit headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1718123456
When exceeded:
Status: 429 Too Many Requests
Retry-After: 45
X-RateLimit-Reset: 1718123456
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please wait 45 seconds.",
"retryAfter": 45
}
}
AI-specific limits return:
Status: 429 Too Many Requests
X-AI-Quota-Type: daily | monthly
X-AI-Quota-Reset: 1718123456
{
"error": {
"code": "AI_QUOTA_EXCEEDED",
"message": "Daily AI message limit reached. Resets in 4 hours.",
"quotaType": "daily",
"resetsAt": "2026-06-13T00:00:00Z"
}
}
Pagination
All list queries follow the Relay Connection specification:
type LessonConnection {
edges: [LessonEdge!]!
pageInfo: PageInfo!
}
type LessonEdge {
node: Lesson!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Pagination Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
first |
Int | 20 | Number of items to fetch (max: 100) |
after |
String | null | Cursor for forward pagination |
last |
Int | null | Number of items backward (use with before) |
before |
String | null | Cursor for backward pagination |
Example with Pagination
query GetLessons {
lessons(
moduleId: "module_xyz",
first: 10,
after: "cursor_abc"
) {
edges {
cursor
node {
id
title
order
estimatedMinutes
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Error Codes
Standard Error Format
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description",
"details": {},
"requestId": "req_abc123"
}
}
Global Error Codes
| Code | HTTP Status | Description |
|---|---|---|
VALIDATION_ERROR |
400 | Input validation failed |
UNAUTHENTICATED |
401 | Authentication required |
TOKEN_EXPIRED |
401 | Access token expired |
TOKEN_INVALID |
401 | Invalid or malformed token |
FORBIDDEN |
403 | Insufficient permissions |
NOT_FOUND |
404 | Resource not found |
CONFLICT |
409 | Resource conflict (duplicate) |
RATE_LIMITED |
429 | Rate limit exceeded |
AI_QUOTA_EXCEEDED |
429 | AI budget exhausted |
TENANT_QUOTA_EXCEEDED |
429 | Tenant-level quota exceeded |
INTERNAL_ERROR |
500 | Unexpected server error |
SERVICE_UNAVAILABLE |
503 | Dependent service unavailable |
Validation Error Details
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Input validation failed",
"details": {
"fields": {
"email": "Invalid email format",
"password": "Password must be at least 8 characters"
}
}
}
}
Versioning Strategy
API Versioning
GraphQL does not use URL versioning. Instead:
- Schema evolution — Fields and types are added without removal. Deprecated fields are marked with
@deprecateddirective and maintained for a minimum of 90 days. - Breaking changes — Announced via Changelog and GitHub Releases. A 30-day migration window is provided.
- No v1/v2 URLs — A single
/graphqlendpoint evolves over time. If a breaking change is unavoidable, a new endpoint/graphql/v2may be introduced and maintained alongside the original for 6 months.
REST Versioning
REST endpoints (webhooks, health) are not versioned at the URL level. Breaking changes to webhook payloads would result in a new webhook event type (e.g., invoice.paid.v2) with the old event maintained for 90 days.
Related Documentation
- ARCHITECTURE.md — System architecture and multi-tenancy deep dive
- CONTEXT.md — Project context, tech stack, and development workflow
- LOCALIZATION.md — Locale support and i18n API details