# 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 ``` 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= 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 ```graphql 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 ```graphql 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 ```json { "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: Handles: checkout.session.completed, invoice.paid, customer.subscription.updated Response 200: { "received": true } ``` ### Clerk Webhook ``` POST /webhooks/clerk Content-Type: application/json Svix-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: "" } ``` **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: "" } ``` **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: "" } ``` **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: ```graphql 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 ```graphql 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 ```json { "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 ```json { "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: 1. **Schema evolution** — Fields and types are added without removal. Deprecated fields are marked with `@deprecated` directive and maintained for a minimum of 90 days. 2. **Breaking changes** — Announced via Changelog and GitHub Releases. A 30-day migration window is provided. 3. **No v1/v2 URLs** — A single `/graphql` endpoint evolves over time. If a breaking change is unavoidable, a new endpoint `/graphql/v2` may 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](./ARCHITECTURE.md) — System architecture and multi-tenancy deep dive - [CONTEXT.md](./CONTEXT.md) — Project context, tech stack, and development workflow - [LOCALIZATION.md](./LOCALIZATION.md) — Locale support and i18n API details