fitai.coach exposes a RESTful API under the /api/v1/ prefix. All endpoints (except the health check and cron trigger) require Clerk JWT authentication. Request bodies are validated with Zod schemas, and rate limiting is applied per-user per-category.
Base URL: /api/v1
Content-Type: application/json (requests and responses)
Authentication: Clerk JWT via credentials: "include" (cookie-based)
Machine-readable spec: a committed OpenAPI 3.0 snapshot lives at docs/openapi.json. It is regenerated from the same Zod registry that powers Swagger UI (shared/openapi.ts) via pnpm docs:openapi and is CI-gated — the Build workflow fails if the committed file drifts from the schemas. Use it for client-code generation, contract testing, or importing into tools like Postman / Insomnia. Endpoints marked below that are not yet registered with the registry are not in the snapshot; coverage is being broadened as routes are migrated.
- Error Responses
- Rate Limiting
- CSRF Protection
- Idempotency
- Request Validation
- Auth Routes
- Account Routes
- Workout Routes
- Custom Exercise Routes
- Training Plan Routes
- Timeline Annotation Routes
- Analytics Routes
- AI and Chat Routes
- Coaching Material Routes
- Preferences Routes
- Email Routes
- Push Notification Routes
- Strava Routes
- Garmin Routes
- Timeline and Export Routes
- Nutrition Routes
All errors follow a standard format:
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": { "issues": [{ "path": "field", "message": "..." }] }
}detailsis only included for validation errors and non-500 responses.- 500 errors always return
"Internal Server Error"to prevent leaking internals.
Validation error example (400):
{
"error": "Invalid workout data",
"code": "VALIDATION_ERROR",
"details": {
"issues": [
{ "path": "date", "message": "Must be a valid date in YYYY-MM-DD format" },
{ "path": "rpe", "message": "Number must be less than or equal to 10" },
{ "path": "exercises[0].exerciseName", "message": "String must contain at least 1 character(s)" }
]
}
}Rate limit error example (429):
HTTP/1.1 429 Too Many Requests
Retry-After: 45
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 1710500045
{
"error": "Rate limit exceeded",
"code": "RATE_LIMITED"
}Not found error example (404):
{
"error": "Workout not found",
"code": "NOT_FOUND"
}Common HTTP status codes:
| Status | Code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, INVALID_CSV |
Invalid input |
| 401 | UNAUTHORIZED |
Missing or invalid auth |
| 404 | NOT_FOUND |
Resource not found |
| 429 | RATE_LIMITED |
Rate limit exceeded (includes Retry-After header) |
| 500 | INTERNAL_SERVER_ERROR |
Server error |
Rate limits are applied per-user (keyed by Clerk userId) and namespaced by category so limits are independent across route groups.
- Default window: 60 seconds
- Strava routes: 15-minute window
- Response on limit:
429withRetry-Afterheader andRATE_LIMITEDcode - Headers: Standard
RateLimit-*headers (RFC 6585) - Storage: PostgreSQL-backed
rate_limit_buckets, shared across app replicas outside tests
Implementation: server/routeUtils.ts — rateLimiter(category, maxRequests, windowMs)
All mutating endpoints (POST/PUT/PATCH/DELETE) require a valid CSRF token. The token is obtained via:
Retrieve a CSRF token for use in subsequent mutating requests.
- Auth: Not strictly required (works pre-login, bound to IP; after login, bound to userId)
- Response:
{ token: string } - Side effects: Sets a signed
__Host-fitai.x-csrfcookie (production) orfitai.x-csrf(development)
The returned token must be sent as the x-csrf-token header on all mutating requests. Missing or invalid tokens result in a 403 Forbidden response.
Mutating endpoints support the X-Idempotency-Key header for safe request replay.
- Header:
X-Idempotency-Key(optional, max 255 characters) - Behavior: When present on a mutating request (POST/PUT/PATCH/DELETE), the server caches the response for 7 days keyed by
(userId, key). Repeat requests with the same key return the cached response without re-executing the handler. - Use case: The client's offline queue sends this header when replaying mutations that were queued while offline, preventing duplicate state changes.
Most protected mutation routes now use a shared builder (protectedPost, protectedPatch, protectedDelete) that codifies middleware order and keeps error responses consistent.
For builder-backed routes, middleware runs in this order:
protectedMutationGuards(auth + idempotency guard chain)- route-local limiter (
rateLimiter(...)) - route-specific middleware list (typically
validateBody(...),validateParams(...), and optional feature guards) - handler (wrapped with
asyncHandlerfor async routes)
This ordering guarantees auth/idempotency and cost guards execute before validation or business logic, while still letting validation produce the canonical VALIDATION_ERROR shape. CSRF is enforced separately at router registration (app.use("/api/v1", csrfProtection)).
Endpoints use Zod schemas for request body validation via two patterns:
validateBody(schema)middleware — Parsesreq.bodywith the schema, returns 400 on failure, replacesreq.bodywith parsed data on success.- Inline
safeParse()— Used in routes that need custom error messages or partial validation.
Validation errors return:
{
"error": "First validation error message",
"code": "VALIDATION_ERROR",
"details": {
"issues": [
{ "path": "field.nested", "message": "Must be at least 1" }
]
}
}File: server/routes/auth.ts
Returns the current authenticated user's profile. Creates the user in the database if they don't exist yet (first-call sync from Clerk).
- Auth: Required
- Rate limit:
authcategory, 20/min - Response:
Userobject (id, email, firstName, lastName, profileImageUrl, preferences)
File: server/routes/account.ts
Permanently delete the authenticated user's account and all associated data (GDPR "right to erasure").
- Auth: Required
- Rate limit:
accountDeletecategory, 3/min - Body: none
- Response:
{ "success": true }(or404 { "error": "User not found", "code": "NOT_FOUND" }) - Side effects, in order:
- Clerk identity is deleted first. If Clerk returns HTTP 404, the identity is treated as already-deleted (idempotent retry); any other error aborts the request so the DB row is not orphaned. Without this ordering,
ensureUserExistson the next authenticated request would silently re-provision the account. - Best-effort Strava deauthorization —
POST https://www.strava.com/oauth/deauthorizeis called with the stored access token. Failures are logged and ignored (non-fatal). - DB user row is deleted. FK
ON DELETE CASCADEcleans up:workout_logs,exercise_sets,training_plans,plan_days,chat_messages,coaching_materials,document_chunks,strava_connections,garmin_connections,custom_exercises,push_subscriptions,ai_usage_logs,idempotency_keys, andtimeline_annotations. - Auth seen-cache eviction —
evictUserFromSeenCache(userId)clears the local and shared 5-minuteensureUserExistscache so a stale Clerk session held by another tab or replica cannot re-provision the user within the TTL window.
- Clerk identity is deleted first. If Clerk returns HTTP 404, the identity is treated as already-deleted (idempotent retry); any other error aborts the request so the DB row is not orphaned. Without this ordering,
Files: server/routes/workouts/ — a composite router (index.ts) that mounts workoutsCrud.routes.ts, workoutsAi.routes.ts, workoutsTimeline.routes.ts, workoutsExport.routes.ts, and workoutsMigration.routes.ts.
List workout logs for the current user with pagination.
- Auth: Required
- Rate limit:
workoutListcategory, 60/min - Query params:
limit(default 50, max capped),offset(default 0) - Response:
WorkoutLog[]
Get the most recent workout log for the current user.
- Auth: Required
- Rate limit:
workoutcategory, 60/min - Response:
WorkoutLogwithexerciseSetsandstructureBlocks - 404: No workouts found
Get a single workout log by ID. If the workout has structure blocks but no exercise sets, the missing sets are derived from the structure on read.
- Auth: Required
- Rate limit:
workoutcategory, 60/min - Response:
WorkoutLogwithexerciseSetsandstructureBlocks - 404: Workout not found
Create a new workout log, optionally with parsed exercises and/or structure blocks.
- Auth: Required
- Rate limit:
workoutcategory, 40/min - Body:
InsertWorkoutLogfields + optionalexercises: ParsedExercise[]+structureBlocks - Validation:
createWorkoutRouteSchema(insertWorkoutLogSchemaextended withexercisesPayloadSchema+structureBlocksPayloadSchema) - Side effects: If user has AI coach enabled, sets
isAutoCoachingflag and queues anauto-coachjob. A text-only write guard (rejectTextOnlyWriteIfNeeded) may reject the request when structured exercise data is required. - Response: Created
WorkoutLogwith expandedexerciseSets
Request example:
{
"date": "2025-03-15",
"focus": "Strength and Running",
"mainWorkout": "4x8 back squat at 80kg, then 5km easy run",
"accessory": "3x12 lunges, 3x15 wall balls",
"duration": 75,
"rpe": 7,
"exercises": [
{
"exerciseName": "back_squat",
"category": "strength",
"confidence": 95,
"sets": [
{ "setNumber": 1, "reps": 8, "weight": 80 },
{ "setNumber": 2, "reps": 8, "weight": 80 },
{ "setNumber": 3, "reps": 8, "weight": 80 },
{ "setNumber": 4, "reps": 8, "weight": 80 }
]
},
{
"exerciseName": "easy_run",
"category": "running",
"confidence": 90,
"sets": [
{ "setNumber": 1, "distance": 5000, "time": 28 }
]
}
]
}Response example:
{
"id": "wl_abc123",
"userId": "user_456",
"date": "2025-03-15",
"focus": "Strength and Running",
"mainWorkout": "4x8 back squat at 80kg, then 5km easy run",
"accessory": "3x12 lunges, 3x15 wall balls",
"duration": 75,
"rpe": 7,
"source": "manual",
"createdAt": "2025-03-15T10:30:00.000Z",
"exerciseSets": [
{
"id": "es_001",
"workoutLogId": "wl_abc123",
"exerciseName": "back_squat",
"category": "strength",
"setNumber": 1,
"reps": 8,
"weight": 80,
"distance": null,
"time": null
},
{
"id": "es_002",
"workoutLogId": "wl_abc123",
"exerciseName": "back_squat",
"category": "strength",
"setNumber": 2,
"reps": 8,
"weight": 80,
"distance": null,
"time": null
}
]
}Update an existing workout log.
- Auth: Required
- Rate limit:
workoutcategory, 40/min - Body: Partial
UpdateWorkoutLogfields + optionalexercises: ParsedExercise[]+structureBlocks - Validation:
updateWorkoutRouteSchema - Response: Updated
WorkoutLog
Delete a workout log and its exercise sets.
- Auth: Required
- Rate limit:
workoutcategory, 40/min - Response:
{ success: true }
Add a single exercise set to a workout log.
- Auth: Required (user must own the workout)
- Rate limit:
workoutSetcategory, 60/min - Body:
addExerciseSetBodySchema - Response:
201Created exercise set (or 404 when the workout doesn't belong to the user)
Update a single exercise set on a workout log.
- Auth: Required (user must own the workout)
- Rate limit:
workoutSetcategory, 120/min - Body:
patchExerciseSetBodySchema - Response: Updated exercise set (or 404)
Delete a single exercise set from a workout log.
- Auth: Required (user must own the workout)
- Rate limit:
workoutSetcategory, 60/min - Response:
{ success: true }(or 404)
Seed a workout log's exercise sets from its linked plan day.
- Auth: Required
- Rate limit:
workoutSetcategory, 20/min - Response:
{ seededCount: number }
Set or clear the score on a single structure block of a workout log.
- Auth: Required
- Rate limit:
workoutcategory, 40/min - Body:
{ score: StructureBlockScore | null } - Response:
{ structureBlocks }(or 404)
Get history stats for a workout log.
- Auth: Required
- Rate limit:
workoutHistorycategory, 60/min - Response: Workout history stats (or 404)
Delete multiple workout logs and/or plan days in a single request.
- Auth: Required
- Rate limit:
workoutBulkDeletecategory, 20/min - Body:
{ workoutLogIds: string[], planDayIds: string[] }— each capped at 100 entries, at least one id required - Response: Bulk delete result (or 404 when a target is not found)
Combine multiple workout logs into a single new workout, deleting the sources.
- Auth: Required
- Rate limit:
workoutcategory, 10/min - Body:
{ newWorkout: InsertWorkoutLog, deleteWorkoutIds: string[] (1-10), skipPlanDayIds?: string[] (max 10) } - Response:
201CreatedWorkoutLog
List workouts that have no parsed exercise sets (candidates for reparsing).
- Auth: Required
- Rate limit:
workoutListcategory, 60/min - Response:
WorkoutLog[]
Re-parse a single workout's text into structured exercise sets using the configured text AI provider. Writes the parsed sets through; responds 422 PARSE_WRITE_THROUGH_REQUIRED when parsing produces no persisted sets.
- Auth: Required (user must own the workout)
- Rate limit:
reparsecategory, 5/min - AI gates:
aiConsentCheck,aiBudgetCheck - Body: Optional
{ prescribedMainWorkout?: string | null, prescribedAccessory?: string | null } - Response:
{ exercises: ParsedExercise[], saved: boolean, setCount: number, rejectedCount: number, rejectionReasons: string[] }
Re-parse all unstructured workouts for the current user.
- Auth: Required
- Rate limit:
batchReparsecategory, 2/min - AI gates:
aiConsentCheck,aiBudgetCheck - Response:
{ total: number, parsed: number, failed: number }
List assisted-migration backfill reviews for the current user.
- Auth: Required
- Rate limit:
migrationReviewscategory, 20/min - Query:
ownerType?(workoutLog|planDay),ownerId?— must be supplied together - Response: Backfill review list
Run an assisted-migration backfill pass for the current user.
- Auth: Required
- Rate limit:
migrationBackfillcategory, 2/min - Response: Backfill result summary
Resolve a single assisted-migration backfill review.
- Auth: Required
- Rate limit:
migrationReviewResolvecategory, 20/min - Body:
{ ownerType: "workoutLog" | "planDay", ownerId: string, action: "accept" | "reject" | "edit", reason?: string | null } - Response:
{ ok: true }(or 404 when the target is not found)
File: server/routes/workouts/workoutsCrud.routes.ts
List all custom exercises defined by the current user.
- Auth: Required
- Rate limit:
customExercisecategory, 60/min - Response:
CustomExercise[]
Create or upsert a custom exercise.
- Auth: Required
- Rate limit:
customExercisecategory, 20/min - Body:
{ name: string, category?: string }(categorydefaults to"conditioning") - Validation:
createCustomExerciseSchema(insertCustomExerciseSchemawithoutuserId) - Response:
CustomExercise
File: server/routes/plans.ts
List all training plans for the current user.
- Auth: Required
- Response:
TrainingPlan[]
Get a training plan with all its days.
- Auth: Required
- Response:
TrainingPlanWithDays
Import a training plan from CSV content.
- Auth: Required
- Rate limit:
planImportcategory, 5/min - Body:
{ csvContent: string, fileName?: string, planName?: string } - Validation:
importPlanRequestSchema(csvContent max 100,000 chars) - Response:
TrainingPlanWithDays
Create the built-in sample Hyrox training plan.
- Auth: Required
- Rate limit:
planSamplecategory, 5/min - Response:
TrainingPlanWithDays
Kick off an asynchronous AI training-plan generation. The request creates a pending plan immediately, enqueues a background plan-generation job (see Integrations — Job Queue), and returns the stub so the client can poll for completion.
- Auth: Required
- Rate limit:
planGeneratecategory, 3/min - AI gates:
aiConsentCheck,aiBudgetCheck - Body:
GeneratePlanInput—{ goal, totalWeeks (1-24), daysPerWeek (2-7), experienceLevel, raceDate?, startDate?, restDays?, focusAreas?, injuries? } - Validation:
generatePlanInputSchema - Response:
202 Acceptedwith the pendingTrainingPlanstub. PollGET /api/v1/plans/:id/generation-statusfor progress. 409 PLAN_GENERATION_IN_PROGRESS: Returned when the user already has a generation in flight (hasInFlightPlanGeneration), so rapid re-submits (triple-clicks, retries) can't enqueue parallel jobs that each burn AI budget and race to write the same user's plans.
Poll the status of an asynchronous plan generation.
- Auth: Required
- Rate limit:
planStatuscategory, 60/min - Response:
{ planId: string, generationStatus: "pending" | "generating" | "ready" | "failed", error?: string }(404 when the plan is not found)
Rename a training plan.
- Auth: Required
- Rate limit:
planUpdatecategory, 20/min - Body:
{ name: string }(1-255 chars) - Response: Updated
TrainingPlan
Update a training plan's goal.
- Auth: Required
- Rate limit:
planUpdatecategory, 20/min - Body:
{ goal: string | null }(max 500 chars) - Response: Updated
TrainingPlan
Update a plan day scoped to its parent plan.
- Auth: Required
- Rate limit:
planDayUpdatecategory, 20/min - Body: Partial
UpdatePlanDay(focus, mainWorkout, accessory, notes, status, scheduledDate) - Validation:
updatePlanDaySchema - Response: Updated
PlanDay
Update a plan day with cleanup (unlinks workout logs when changing status away from completed).
- Auth: Required
- Rate limit:
planDayUpdatecategory, 20/min - Body: Partial
UpdatePlanDay - Response: Updated
PlanDay
Update only the status, scheduled date and/or skip reason of a plan day.
- Auth: Required
- Rate limit:
planDayStatuscategory, 20/min - Body:
{ status?: "planned" | "completed" | "missed" | "skipped", scheduledDate?: string | null, skipReason?: "ill" | "injured" | "schedule" | "low_energy" | null } - Response: Updated
PlanDay - Notes:
skipReasonis only meaningful alongsidestatus: "skipped"— any other status clears it. Omitting the key leaves an existing reason untouched; sendingnullclears it explicitly. Sending onlyscheduledDate(nostatus) takes the reschedule path and skips the status-transition check.
Delete a training plan and all its days (cascade).
- Auth: Required
- Rate limit:
planDeletecategory, 10/min - Response:
{ success: true }
Delete a single plan day.
- Auth: Required
- Rate limit:
planDayDeletecategory, 10/min - Response:
{ success: true }
Schedule a plan by assigning dates to all days starting from a given date.
- Auth: Required
- Rate limit:
planSchedulecategory, 10/min - Body:
{ startDate: "YYYY-MM-DD" } - Validation:
schedulePlanRequestSchema - Response:
{ success: true }
List the exercise sets for a plan day. With ?includeStructure=true, also returns structure blocks (and derives missing sets from structure when needed).
- Auth: Required
- Rate limit:
planDaySetcategory, 60/min - Query:
includeStructure?("true") - Response:
ExerciseSet[], or{ exerciseSets, structureBlocks }whenincludeStructure=true(or 404)
Add a single exercise set to a plan day.
- Auth: Required
- Rate limit:
planDaySetcategory, 60/min - Body:
addExerciseSetBodySchema - Response:
201Created exercise set (or 404)
Update a single exercise set on a plan day.
- Auth: Required
- Rate limit:
planDaySetcategory, 120/min - Body:
patchExerciseSetBodySchema - Response: Updated exercise set (or 404)
Delete a single exercise set from a plan day.
- Auth: Required
- Rate limit:
planDaySetcategory, 60/min - Response:
{ success: true }(or 404)
Replace the structure blocks of a plan day.
- Auth: Required
- Rate limit:
planDaySetcategory, 60/min - Body:
{ structureBlocks: StructureBlock[] } - Response: The updated plan day structure (or 404)
Re-parse a plan day's mainWorkout/accessory text into structured exercise sets, replacing existing prescribed rows. Responds 422 PARSE_WRITE_THROUGH_REQUIRED when parsing produces no persisted sets.
- Auth: Required
- Rate limit:
planDayReparsecategory, 5/min - AI gates:
aiConsentCheck,aiBudgetCheck - Body: Optional
{ mainWorkout?: string | null, accessory?: string | null } - Response:
{ exercises, saved: true, setCount, rejectedCount, rejectionReasons }(or 404)
Photo sibling of /reparse — re-parse a plan day's prescribed exercises from an uploaded image. Same replace semantics.
- Auth: Required
- Rate limit:
planDayReparsecategory, 5/min - AI gates:
aiConsentCheck,aiBudgetCheck - Body:
{ imageBase64, mimeType } - Response: Same shape as
/reparse(or 404)
Manually refresh the AI coach note (ai_rationale) for a planned day. The service enforces a 30-second cooldown.
- Auth: Required
- Rate limit:
coachNoteRegeneratecategory, 10/min - AI gates:
aiConsentCheck,aiBudgetCheck - Response: The updated plan day, or
429 COOLDOWN(withRetry-Afterheader) when called inside the cooldown window
File: server/routes/timelineAnnotations.ts
User-authored bands spanning [startDate, endDate] that annotate injury, illness, travel, or rest periods on the Timeline and as shaded bands on Analytics charts. The DB layer (timeline_annotations table) enforces type IN ('injury','illness','travel','rest') and end_date >= start_date. Per-user ownership is enforced at the storage layer — mismatched IDs silently return 404 to avoid leaking existence.
List all annotations for the authenticated user, ordered by startDate ASC.
- Auth: Required
- Rate limit:
annotationscategory, 60/min - Response:
TimelineAnnotation[]
Create a new annotation.
- Auth: Required
- Rate limit:
annotationscategory, 20/min - Body:
{ startDate: "YYYY-MM-DD", endDate: "YYYY-MM-DD", type: "injury" | "illness" | "travel" | "rest", note?: string }(notemax 500 chars) - Validation:
insertTimelineAnnotationSchema(Zod), with a.refinethatendDate >= startDatewhen both dates are present - Response:
201 TimelineAnnotation
Partially update an annotation. The handler fetches the existing row, merges the partial over it, and re-checks the date bounds before writing so a single-field PATCH cannot slip an invalid range past Zod.
- Auth: Required
- Rate limit:
annotationscategory, 20/min - Body: Partial
{ startDate?, endDate?, type?, note? } - Validation:
updateTimelineAnnotationSchema - Response:
TimelineAnnotation(or 404 when the id doesn't belong to the user)
Delete an annotation.
- Auth: Required
- Rate limit:
annotationscategory, 20/min - Response:
{ success: true }(or 404 when the id doesn't belong to the user)
File: server/routes/analytics.ts
All analytics endpoints support optional date filtering via query parameters: ?from=YYYY-MM-DD&to=YYYY-MM-DD.
Coalesced request cache. Exercise sets and workout logs used by these routes pass through two in-memory promise caches (getExerciseSetsCoalesced and getWorkoutLogsCoalesced) keyed by userId + from + to. The cache holds the pending promise, so three concurrent requests for the same user/window trigger a single database query. Parameters:
| Knob | Value | Source |
|---|---|---|
| TTL | 5 minutes (ANALYTICS_CACHE_TTL_MS) |
server/constants.ts |
| Max entries per cache | 500 (MAX_CACHE_SIZE) |
server/routes/analytics.ts |
| Eviction | Expired entries first, then oldest-by-timestamp once over the size cap | evictStale() |
| Failure behavior | The rejected promise is evicted so the next caller retries immediately | .catch in getExerciseSetsCoalesced / getWorkoutLogsCoalesced |
Calculate personal records across all exercises.
- Auth: Required
- Rate limit:
analyticscategory, 20/min - Query:
from?,to? - Response:
PersonalRecord[]— max weight, max distance, best time per exercise category
Calculate per-exercise analytics (volume, intensity trends).
- Auth: Required
- Rate limit:
analyticscategory, 20/min - Query:
from?,to? - Response: Exercise analytics breakdown
Calculate weekly training summaries, category totals, station coverage, and week-over-week deltas.
-
Auth: Required
-
Rate limit:
analyticscategory, 20/min -
Query:
from?,to? -
Response shape:
{ weeklySummaries: WeeklySummary[], workoutDates: string[], categoryTotals: { /* per-category totals */ }, stationCoverage: { /* Hyrox station coverage */ }, currentStats: { totalWorkouts: number, avgPerWeek: number, totalDuration: number, avgDuration: number, avgRpe: number | null, }, // Omitted when no meaningful previous window exists — e.g. the user // picked "all time" so `from` is absent. previousStats?: { totalWorkouts: number, avgPerWeek: number, totalDuration: number, avgDuration: number, avgRpe: number | null, }, }
-
Previous-window derivation (
computePreviousWindow): The previous period is the equal-length, non-overlapping range ending the day beforefrom. Iftois omitted, the current window's upper bound is pinned to midnight UTC of today (not wall-clocknow) so the previous window doesn't drift across the day. Returnsnullwhenfromis absent, and the route responds withoutpreviousStats. -
The client's
DeltaIndicatorcomponent renders the percentage change betweencurrentStatsandpreviousStatsfor each of the four stat cards.
Predict the athlete's HYROX finish time from their logged history. Stored-first: returns the last persisted prediction instantly (no AI spend) so the tab paints on open without a spinner; ?refresh=1 (the manual refresh button) — or the absence of any stored row — regenerates and persists a fresh prediction. The stored row is also kept warm by the midnight analytics recompute job.
- Auth: Required
- Rate limit:
race-predictioncategory, 12/min - AI gates: None — the service degrades to a deterministic estimate when AI is disabled, unconsented, or over budget, and only calls the model when consent + budget allow.
- Query:
refresh?—refresh=1forces regeneration and persistence - Response:
RacePredictionResponse & { generatedAt: string, stale: boolean }— on a stored read,staleistruewhen a workout was logged aftergeneratedAt; a freshly generated prediction returnsstale: false.
File: server/routes/ai.ts
Parse free-text or voice input into structured exercise data using the configured text AI provider.
- Auth: Required
- Rate limit:
parsecategory, 5/min - Body:
{ text: string }(1-2000 chars) - Validation:
parseExercisesRequestSchema - Response:
ParsedExercise[]with confidence scores and category classification
Request example:
{
"text": "3 sets bench 225lbs x 8, then 3 miles in 24 min"
}Response example:
[
{
"exerciseName": "bench_press",
"category": "strength",
"confidence": 95,
"missingFields": [],
"sets": [
{ "setNumber": 1, "reps": 8, "weight": 225 },
{ "setNumber": 2, "reps": 8, "weight": 225 },
{ "setNumber": 3, "reps": 8, "weight": 225 }
]
},
{
"exerciseName": "easy_run",
"category": "running",
"confidence": 80,
"missingFields": [],
"sets": [
{ "setNumber": 1, "distance": 4828, "time": 24 }
]
}
]Parse free text into a structured workout (blocks/structure) using the configured text AI provider.
- Auth: Required
- Rate limit:
parsecategory, 5/min (shared budget withparse-exercises) - AI gates:
aiConsentCheck,aiBudgetCheck - Body:
{ text: string }(1-2000 chars) - Validation:
parseExercisesRequestSchema - Response: Parsed workout structure
Parse a photo of a workout plan (whiteboard, printout, screenshot) into structured exercise data using Gemini's multi-modal vision model. Images are expected to be compressed client-side via client/src/lib/image.ts (compressImage) before upload. A route-scoped express.json({ limit: "10mb" }) parser handles the base64 body.
- Auth: Required
- Rate limit:
parsecategory, 5/min (shared budget with text parsing) - AI gates:
aiConsentCheck,aiBudgetCheck - Body:
{ imageBase64: string, mimeType: "image/jpeg" | "image/png" | "image/webp" } - Response: Same
ParsedExercise[]shape as/api/v1/parse-exercises.
Photo sibling of /parse-workout-structure — parse a workout-structure photo into structured blocks.
- Auth: Required
- Rate limit:
parsecategory, 5/min (shared budget) - AI gates:
aiConsentCheck,aiBudgetCheck - Body:
{ imageBase64, mimeType } - Response: Parsed workout structure
Re-parse an existing workout's exercises against a newly-uploaded photo. Used from the workout detail dialog when the coach updates the prescribed block or when the athlete captures the post-session whiteboard. Defined in server/routes/workouts/workoutsAi.routes.ts.
- Auth: Required (user must own the workout)
- Rate limit:
reparsecategory, 5/min - AI gates:
aiConsentCheck,aiBudgetCheck - Body:
{ imageBase64, mimeType }(same as above) - Response:
{ exercises, saved: true, setCount, rejectedCount, rejectionReasons }, or422 PARSE_WRITE_THROUGH_REQUIREDwhen no sets are persisted.
Send a message to the AI coach and receive a complete response.
- Auth: Required
- Rate limit:
chatcategory, 10/min - Body:
{ message: string (1-1000 chars), history?: ChatMessage[] (max 20) } - Validation:
chatRequestSchema - Response:
{ response: string, ragInfo: RagInfo }
Send a message to the AI coach and receive a streaming response via Server-Sent Events.
- Auth: Required
- Rate limit:
chatcategory, 10/min - Body: Same as
/api/v1/chat - Response headers:
Content-Type: text/event-stream,Cache-Control: no-cache - SSE events:
{ ragInfo: RagInfo }— First event with RAG metadata{ text: string }— Streaming text chunks{ done: true }— Stream complete{ error: string }— Stream error
Request example:
{
"message": "How should I pace my sled push at competition?",
"history": [
{ "role": "user", "content": "I have a Hyrox race in 6 weeks" },
{ "role": "assistant", "content": "Great! Let me help you prepare..." }
]
}SSE response sequence:
data: {"ragInfo":{"source":"rag","chunkCount":3,"materialCount":2}}
data: {"text":"For the sled push, "}
data: {"text":"I recommend breaking it into "}
data: {"text":"three phases: an aggressive start, steady middle, and controlled finish."}
data: {"done":true}
If an error occurs mid-stream:
data: {"error":"Stream error"}
Retrieve saved chat messages for the current user, cursor-paginated.
- Auth: Required
- Query:
limit?(1-200),before?(ISO datetime),beforeId?(string) —beforeandbeforeIdmust be supplied together - Response:
ChatMessage[](plain array for backward compatibility). When more rows exist, the cursor for the next page is returned in theX-Next-Cursor(timestamp) andX-Next-Cursor-Id(row id) response headers, both of which must be echoed back on the next request.
Save a chat message to history.
- Auth: Required
- Rate limit:
chatMessagecategory, 20/min - Body:
{ userId: string, role: "user" | "assistant", content: string (1-50000 chars) } - Validation:
insertChatMessageSchema - Response: Saved
ChatMessage
Clear all chat messages for the current user.
- Auth: Required
- Rate limit:
chatHistoryDeletecategory, 5/min - Response:
{ success: true }
Return the last stored Coach Insights analysis instantly, with no AI spend, so the Analytics tab paints the previous result on open instead of a blank state.
- Auth: Required
- Rate limit:
analyticscategory, 60/min - AI gates: None (read-only; never calls the model)
- Response:
{ ...CoachInsightsResult, generatedAt: string, stale: boolean }—staleistruewhen a workout was logged aftergeneratedAt. Returns{ insights: null }when the user has never generated insights.
Regenerate the single-shot AI analysis of the athlete's progress against their stated goal and persist it to the durable analytics_results store. Generation lives in services/coachInsightsService so the route and the midnight recompute job share one path.
- Auth: Required
- Rate limit:
suggestionscategory, 3/min - AI gates:
aiConsentCheck,aiBudgetCheck - Response:
{ ...CoachInsightsResult, stale: false }— freshly generated against the current latest workout, so never stale (CoachInsightsResultincludesinsightsandragInfo).
Generate AI coaching suggestions for upcoming planned workouts.
- Auth: Required
- Rate limit:
suggestionscategory, 3/min - AI gates:
aiConsentCheck,aiBudgetCheck - Response:
{ suggestions: WorkoutSuggestion[], ragInfo: RagInfo } - Note: Returns empty suggestions if no upcoming planned workouts exist.
Apply a generated timeline AI suggestion to a plan day's field.
- Auth: Required
- Rate limit:
suggestionApplycategory, 10/min - AI gates:
aiConsentCheck - Body:
{ workoutId: string, targetField: "notes" | "mainWorkout" | "accessory", action: "replace" | "append", recommendation: string, rationale?: string | null, aiSource?: "rag" | "legacy" | "none" | null } - Response: The updated plan day (or 404 when the plan day is not found)
Inspect the AI suggestion trace and metadata for a plan day. Debugging aid.
- Auth: Required
- Response:
{ workoutId, focus, aiSource, aiRationale, aiNoteUpdatedAt, trace, debugSummary }(or 404)
File: server/routes/coaching.ts
List all coaching materials for the current user.
- Auth: Required
- Response:
CoachingMaterial[]
Create a new coaching material. Triggers background embedding via pg-boss queue.
- Auth: Required
- Rate limit:
coachingcategory, 10/min - AI gates:
aiConsentCheck,aiBudgetCheck - Body limit: 2MB (elevated from default 100kb)
- Body:
{ title: string (1-255 chars), content: string (1-1,500,000 chars), type: "principles" | "document" } - Validation:
createMaterialSchema(insertCoachingMaterialSchemawithoutuserId) - Side effects: Queues
embed-coaching-materialjob for RAG chunking/embedding. - Response:
201CreatedCoachingMaterial
Update a coaching material. Re-embeds if content or title changed.
- Auth: Required
- Rate limit:
coachingcategory, 10/min - AI gates:
aiConsentCheck,aiBudgetCheck - Body: Partial
{ title?, content?, type? } - Side effects: Queues re-embedding if content or title changed.
- Response: Updated
CoachingMaterial
Delete a coaching material. Document chunks are cascade-deleted via FK.
- Auth: Required
- Rate limit:
coachingcategory, 10/min - Response:
{ success: true }
Check the RAG pipeline status (embedding counts, dimension info).
- Auth: Required
- Response: RAG status object
Re-embed all coaching materials for the current user.
- Auth: Required
- Rate limit:
coachingcategory, 5/min - AI gates:
aiConsentCheck,aiBudgetCheck - Response: Re-embed result summary
File: server/routes/preferences.ts
Get the current user's preferences.
- Auth: Required
- Response: Serialized preferences —
{ weightUnit, distanceUnit, weeklyGoal, emailNotifications, emailWeeklySummary, emailMissedReminder, showAdherenceInsights, aiCoachEnabled, trainingStyleId, trainingStylePreviousId, trainingStyleChangedAt, trainingStyleRecomputeNow, onboardingCompleted, mafAge, mafInjuryIllnessMedication, mafConsistency, mafTrend, mafHrDataAvailable, mafHr, mafBaselineTestScheduledAt }plus two derived fields:planWeeklyDensity(the active plan's per-week density, ornull) andweeklyGoalExceedsPlan(boolean hint when the user'sweeklyGoalexceeds that density).
Update user preferences.
- Auth: Required
- Rate limit:
preferencescategory, 20/min - Body: Partial of the serialized preference fields above (e.g.
weightUnit?: "kg" | "lbs",distanceUnit?: "km" | "miles",weeklyGoal?, theemail*toggles,aiCoachEnabled?,showAdherenceInsights?,onboardingCompleted?,trainingStyleId?, and themaf*fields). - Validation:
updateUserPreferencesSchema - MAF validation: Switching
trainingStyleIdtomaf_methodrequiresmafAge,mafConsistency, andmafTrendto be set (in the body or already persisted); otherwise the route returns400 { code: "MAF_SETUP_REQUIRED" }. - Response: Updated serialized preferences object
- Email toggle semantics:
emailNotificationsis the master switch — whenfalse, no email is sent regardless of the per-type flags.emailWeeklySummaryandemailMissedRemindergate the individual categories and take effect only when the master is on. All three default tofalseat the database level for new users (GDPR-compliant opt-in). - AI consent semantics:
aiCoachEnabledgates every outbound AI provider call (workout parsing, chat, auto-coach, embeddings, and image parsing). It defaults tofalsefor new users; the AI features are hidden or disabled in the UI until the user explicitly opts in. Flipping it tofalseimmediately stops new AI requests; already-persisted chat history and plan AI artifacts remain until the user deletes them. - Onboarding completion:
onboardingCompletedstores whether the welcome flow has finished across devices. It is app state, not a visible Settings preference.
File: server/routes/email.ts
Manually trigger email checks for the current user (weekly summary, missed reminders).
- Auth: Required (Clerk JWT)
- Rate limit:
emailCheckcategory, 5/min - Response:
{ sent: string[] }— list of email types sent
External cron trigger endpoint for batch email processing across all users.
- Auth:
x-cron-secretheader (timing-safe comparison withCRON_SECRETenv var) - No Clerk auth required
- Response: Cron job result summary
Internal structured exercise health rollup endpoint. Defined in server/routes/analytics.ts.
- Auth: Clerk auth plus
x-internal-analytics-secretheader (timing-safe comparison withINTERNAL_ANALYTICS_SECRETenv var) - Rate limit:
internalAnalyticscategory, 5/min - Response:
{ rollups, counters }
File: server/routes/push.ts
Web Push (VAPID) endpoints used by the PWA to deliver missed-workout nudges and weekly summary notifications. All endpoints return 404 PUSH_NOT_CONFIGURED when VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY are not set.
Return the server's VAPID public key so the client can call PushManager.subscribe.
- Auth: Required
- Response:
{ publicKey: string }
Persist a PushSubscription for the authenticated user. Multiple endpoints per user are allowed (one per device).
- Auth: Required
- Rate limit:
pushcategory, 10/min - Body:
{ endpoint: string, keys: { p256dh: string, auth: string } } - Response:
{ success: true }
Remove a specific subscription endpoint for the authenticated user.
- Auth: Required
- Rate limit:
pushcategory, 10/min - Body:
{ endpoint: string } - Response:
{ success: true }
Dispatch a test notification to every registered subscription for the authenticated user. Used by the Settings UI to verify the PWA install is wired up correctly.
- Auth: Required
- Rate limit:
pushcategory, 5/min - Response:
{ success: true, sent: number }
File: server/strava.ts
Check if the current user has a Strava connection.
- Auth: Required
- Response:
{ connected: boolean, athleteId?: string, lastSyncedAt?: string, requiresReauth?: boolean }—requiresReauth: truemeans Strava rejected our stored credentials (the user revoked the app on strava.com); the client should offer a Reconnect flow.
Generate a Strava OAuth authorization URL with CSRF-protected signed state.
- Auth: Required
- Rate limit: IP-based, 20 per 15 minutes
- Response:
{ url: string }— Redirect URL for Strava OAuth - State parameter: HMAC-SHA256 signed with
userId:timestamp:nonce:signature, max age enforced, single-use (atomically claimed on callback)
OAuth callback handler. Exchanges authorization code for tokens, encrypts and stores them.
- Auth: Not required (redirect from Strava)
- Rate limit: IP-based, 20 per 15 minutes
- Query:
code,state(CSRF-verified, single-use — replays redirect to/settings?strava=error),scope - Side effects: Creates
stravaConnectionsrecord with AES-256-GCM encrypted tokens; clears anyrequires_reauthtombstone on reconnect - Response: Redirect to
/settings
Incrementally sync Strava activities into workout logs (since lastSyncedAt with a 7-day overlap; 90-day backfill on first sync; up to 5 × 200-activity pages per call).
- Auth: Required
- Rate limit: IP-based, 5 per 15 minutes
- Side effects: Fetches activities from Strava API, maps to WorkoutLog format, deduplicates by
stravaActivityId, auto-refreshes expired tokens (serialized under a per-user advisory lock), enriches calories for the newest ≤25 imports from the activity-detail endpoint, advances thelastSyncedAtcursor. - Response:
{ success: true, imported: number, skipped: number, total: number, hasMore: boolean }—hasMore: truemeans the page cap was hit and another sync will continue where this one stopped. - Errors:
401 { code: "STRAVA_REAUTH_REQUIRED" }(revoked — reconnect needed),401 { code: "UNAUTHORIZED" }(not connected),429 { code: "RATE_LIMITED", retryAfterSeconds }(Strava rate limit, after retries),502 { code: "EXTERNAL_API_ERROR" }(transient upstream failure).
Disconnect the Strava integration. Performs a best-effort upstream POST /oauth/deauthorize (non-fatal on failure) before deleting the local connection.
- Auth: Required
- Response:
{ success: true }
File: server/garmin.ts
Garmin Connect sync uses a reverse-engineered SSO flow (email + password), not a public OAuth application. See Integrations → Garmin Connect for the rationale, safety stack, and storage model.
All mutating routes apply protectedMutationGuards (auth + CSRF + idempotency). Every route short-circuits with HTTP 503 GARMIN_CIRCUIT_OPEN when the global 429 circuit breaker is tripped.
Returns the Garmin connection state for the authenticated user.
- Auth: Required
- Response:
{ connected: false }or{ connected: true, garminDisplayName: string | null, lastSyncedAt: string | null, lastError: string | null }
Authenticate with Garmin using email + password and persist the encrypted credentials / OAuth tokens.
- Auth: Required
- Rate limit:
garmin-connectcategory, 5 per 15-minute window per user - Body:
{ email: string (valid email, max 254), password: string (1-256) } - Behavior: Logs into Garmin before writing any DB row — nothing is stored on failure. Fetches
getUserProfile()to capture the display name (optional; non-fatal if it fails). - Responses:
200 { success: true, garminDisplayName: string | null }400 { code: "BAD_REQUEST" }— invalid email / empty password401 { code: "GARMIN_AUTH_FAILED" }— invalid credentials or 2SV enabled (see error translation inserver/garmin.ts)409 { code: "GARMIN_BUSY" }— another Garmin op for the same user is in progress (per-user mutex)503 { code: "GARMIN_CIRCUIT_OPEN" }— global 429 breaker is tripped
Removes the garmin_connections row for the user (credentials, tokens, display name).
- Auth: Required
- Response:
{ success: true }
Imports the most recent activities from Garmin into workout_logs.
- Auth: Required
- Rate limit:
garmin-synccategory, 5 per 15-minute window per user - Preflight rejections (checked before login):
404 { code: "GARMIN_NOT_CONNECTED" }429 { code: "GARMIN_SYNC_TOO_SOON" }— less than 5 minutes sincelastSyncedAt401 { code: "GARMIN_RECONNECT_REQUIRED" }— priorlastErroris set; user must disconnect + reconnect503 { code: "GARMIN_CIRCUIT_OPEN" }— global 429 breaker tripped
- Behavior: Calls
client.getActivities(0, 20), dedupes against the partial unique index(user_id, garmin_activity_id) WHERE garmin_activity_id IS NOT NULL, and inserts the new rows viaonConflictDoNothing. - Success response:
{ success: true, imported: number, skipped: number, total: number }—importedis the true insert count; anything caught by the partial index is rolled intoskipped. - Error responses:
401 GARMIN_AUTH_FAILED,502 GARMIN_API_ERROR(withlastErrorpersisted),409 GARMIN_BUSY.
Files: server/routes/workouts/workoutsTimeline.routes.ts, server/routes/workouts/workoutsExport.routes.ts, and the exercise-history route in server/routes/workouts/workoutsCrud.routes.ts.
Get merged timeline of planned and logged workouts.
- Auth: Required
- Rate limit:
timelinecategory, 60/min - Query:
planId?(filter by plan),limit?(default capped),offset? - Response:
TimelineEntry[]— merged planned + logged workouts sorted by date
Response example:
[
{
"id": "pd_101",
"date": "2025-03-17",
"type": "planned",
"status": "planned",
"focus": "Sled Push + SkiErg",
"mainWorkout": "4x50m sled push at 100kg, 3x500m SkiErg",
"accessory": "3x15 wall balls",
"notes": null,
"planDayId": "pd_101",
"workoutLogId": null,
"weekNumber": 3,
"dayName": "Monday",
"planName": "Hyrox 8-Week Prep",
"planId": "plan_abc"
},
{
"id": "wl_202",
"date": "2025-03-16",
"type": "logged",
"status": "completed",
"focus": "Easy Run",
"mainWorkout": "5km easy run",
"accessory": null,
"notes": "Felt good, kept HR under 145",
"duration": 30,
"rpe": 4,
"planDayId": null,
"workoutLogId": "wl_202",
"source": "strava",
"exerciseSets": [
{
"id": "es_501",
"workoutLogId": "wl_202",
"exerciseName": "easy_run",
"category": "running",
"setNumber": 1,
"distance": 5000,
"time": 30,
"reps": null,
"weight": null
}
],
"calories": 320,
"distanceMeters": 5000,
"avgHeartrate": 142,
"maxHeartrate": 155
}
]Get historical exercise sets for a specific exercise, newest session first.
The name is resolved through normalizeExerciseName, so RDL finds
romanian_deadlift; names it can't resolve fall back to an exact match.
- Auth: Required
- Rate limit:
exerciseHistorycategory, 120/min — the client fires one request per distinct exercise when a session is opened, so this has its own bucket rather than sharingworkoutHistory - Query:
sessions(1–20, optional) — bounds the number of distinct training dates returned, not the number of rows, so a session's sets are never returned in part - Response: Exercise set history with dates
Export all training data as CSV or JSON.
- Auth: Required
- Rate limit:
exportcategory, 5/min - Query:
format—"csv"(default) or"json" - Response: File download with appropriate Content-Type and Content-Disposition headers
Base path: /api/v1/nutrition (paths in the table are relative to it). Files: server/routes/nutrition/*.
The entire nutrition surface is gated by the NUTRITION_ENABLED server flag — when it is not "true", every route below returns 404. The AI-backed routes (/parse/*, POST /insights) additionally require AI consent and budget. These routes are not yet registered with the OpenAPI registry, so they are absent from docs/openapi.json and Swagger UI; this catalog and Nutrition & Fuelling § API surface are the reference until they are migrated.
| Method | Path | Purpose | Rate limit (per min) |
|---|---|---|---|
| GET | /foods/search |
Search local cache + Edamam + USDA + Open Food Facts (fuzzy / synonym / optional semantic) | nutritionSearch (30) |
| GET | /foods/recent |
Recently logged foods (FoodWithPortionMemory[] — each food plus its lastQuantityG / lastMealType) |
nutritionRead (60) |
| GET | /foods/custom |
The user's custom foods | nutritionRead (60) |
| POST | /foods/barcode |
Barcode → food (Open Food Facts) | nutritionBarcode (30) |
| POST | /foods |
Create a custom food (+ servings) | nutritionWrite (30) |
| GET | /foods/:id |
Food + named servings | nutritionRead (60) |
| PATCH | /foods/:id |
Edit a custom food | nutritionWrite (30) |
| DELETE | /foods/:id |
Delete a custom food (409 if referenced by a log) |
nutritionWrite (30) |
| POST | /foods/:id/servings |
Add a named serving | nutritionWrite (30) |
| DELETE | /foods/:id/servings/:servingId |
Delete a serving | nutritionWrite (30) |
| GET | /favorites |
List favourites (FoodWithPortionMemory[] — each food plus its lastQuantityG / lastMealType) |
nutritionRead (60) |
| POST | /favorites |
Add a favourite | nutritionFav (30) |
| DELETE | /favorites/:foodId |
Remove a favourite | nutritionFav (30) |
| POST | /logs |
Log a food | nutritionLog (60) |
| PATCH | /logs/:id |
Edit a log entry | nutritionLog (60) |
| DELETE | /logs/:id |
Delete a log entry | nutritionLog (60) |
| POST | /logs/repeat |
Repeat a day / meal | nutritionLog (20) |
| POST | /logs/batch |
Confirm reviewed parsed items | nutritionLog (60) |
| GET | /summary |
Daily totals + per-meal breakdown, incl. mealTargets |
nutritionRead (60) |
| GET | /session-fuelling/:workoutId |
Pre/post-session fuelling windows | nutritionRead (60) |
| GET | /block |
Daily intake macros vs. training UTSS | nutritionRead (60) |
| GET | /targets |
Current target + history | nutritionRead (60) |
| POST | /targets |
Set / replace the target version | nutritionWrite (30) |
| GET | /micros |
The day's micronutrients vs. RDI | nutritionRead (60) |
| POST | /parse/text |
Natural-language meal → items (AI) | parse (5) + consent + budget |
| POST | /parse/photo |
Photo → items (AI) | parse (5) + consent + budget |
| GET | /insights |
Last stored AI nutrition analysis | nutritionRead (60) |
| POST | /insights |
Regenerate the analysis (AI) | suggestions (3) + consent + budget |
| GET | /recipes |
List recipes | nutritionRead (60) |
| POST | /recipes |
Create a recipe | nutritionWrite (30) |
| GET | /recipes/:id |
Recipe + ingredients + per-serving macros | nutritionRead (60) |
| PATCH | /recipes/:id |
Edit a recipe | nutritionWrite (30) |
| DELETE | /recipes/:id |
Delete a recipe | nutritionWrite (30) |
See Nutrition & Fuelling for request/response shapes, the per-100g scaling model, and AI safety details.
- AI and RAG -- Architecture of the RAG pipeline, embedding strategy, and coaching material processing.
- Authentication -- Clerk JWT setup, middleware configuration, and session management.
- Nutrition & Fuelling -- The full nutrition subsystem: data model, food search, fuelling, and AI usage.