Complete reference for all HTTP endpoints exposed by the backend.
Phase 19: OpenAPI Integration
The API now provides interactive documentation via Swagger UI and a machine-readable OpenAPI 3.0 specification.
| Resource | URL | Description |
|---|---|---|
| Swagger UI | /docs |
Interactive API explorer with request/response examples |
| OpenAPI Spec | /openapi.json |
JSON schema for API contract (OpenAPI 3.0) |
- Navigate to
http://localhost:4000/docs(development) orhttps://api.fieldtrack.app/docs(production) - Click Authorize button in the top right
- Enter your JWT token in the format:
Bearer <your-jwt-token> - Click Authorize to save
- All subsequent requests will include the authentication header
curl -X POST http://localhost:4000/attendance/check-in \
-H "Authorization: Bearer <your-jwt-token>" \
-H "Content-Type: application/json"Endpoints are organized by the following tags:
- health — Health check and system status endpoints
- attendance — Attendance tracking and session management
- locations — Location tracking and route calculation
- expenses — Expense reporting and management
- analytics — Business analytics and reporting (ADMIN only)
- admin — Administrative operations (ADMIN role required)
All endpoints except the public health/metrics routes require a Supabase-issued JWT passed as a Bearer token.
Authorization: Bearer <supabase-jwt>
The JWT payload must include these claims (validated by Zod on every request):
| Claim | Type | Description |
|---|---|---|
sub |
string (UUID) |
User identity — used as the primary actor identifier |
organization_id |
string (UUID) |
Tenant identifier — enforced on every data query |
role |
"EMPLOYEE" | "ADMIN" |
Determines access to protected endpoints |
All error responses share this structure:
{
"success": false,
"error": "Human-readable error message",
"requestId": "uuid-of-this-request"
}Validation errors (400) include details:
{
"success": false,
"error": "Validation failed",
"details": [{ "path": ["field"], "message": "must be a valid UUID" }],
"requestId": "..."
}| Code | Meaning |
|---|---|
200 |
Success |
201 |
Resource created |
400 |
Validation failure or business rule violation |
401 |
Missing or invalid JWT |
403 |
JWT valid but role insufficient |
404 |
Resource not found |
429 |
Rate limit exceeded ({ success: false, error: "Too many requests", retryAfter: "Ns" }) |
500 |
Unexpected server error |
Every response includes:
| Header | Value |
|---|---|
x-request-id |
UUID generated per-request (matches requestId in error bodies) |
Public health check. No authentication required.
Response 200:
{ "status": "ok", "timestamp": "2026-03-10T12:00:00.000Z" }Prometheus scrape endpoint. Returns metrics in OpenMetrics text format.
- No authentication required
- Scraped automatically by Prometheus every 15 s
- Required response format:
Content-Type: application/openmetrics-text(for exemplar support)
Internal operational snapshot. Requires JWT + ADMIN role.
Response 200:
{
"uptimeSeconds": 3600,
"queueDepth": 2,
"totalRecalculations": 1540,
"totalLocationsInserted": 287430,
"avgRecalculationMs": 42.7
}| Field | Description |
|---|---|
uptimeSeconds |
Seconds since process start |
queueDepth |
Sessions currently waiting in the BullMQ worker queue |
totalRecalculations |
Cumulative completed distance recalculations since last restart |
totalLocationsInserted |
Cumulative GPS points written (after deduplication) since last restart |
avgRecalculationMs |
Rolling average recalculation latency (last 100 jobs) |
Development / staging only. Disabled in production (NODE_ENV=production returns 404).
Pings Redis via the BullMQ connection and produces an OTel span (visible in Tempo service graph).
Response 200:
{ "status": "ok", "redis": "PONG" }Response 503:
{ "status": "error", "redis": "unreachable" }All attendance endpoints require JWT authentication. ADMIN routes additionally require role: "ADMIN".
Start a new attendance session. Creates a new record with checked_in_at = now() and status = "ACTIVE".
Auth: Any authenticated user
Request body: None
Response 201:
{
"success": true,
"data": {
"id": "uuid",
"employee_id": "uuid",
"organization_id": "uuid",
"checked_in_at": "2026-03-10T08:00:00.000Z",
"checked_out_at": null,
"status": "ACTIVE"
}
}Error 400: "Cannot check in: you already have an active session. Check out first." — thrown by EmployeeAlreadyCheckedIn
Close the caller's active session. Sets checked_out_at = now(), status = "CLOSED", and enqueues a BullMQ job to recalculate distance and duration.
Auth: Any authenticated user
Request body: None
Response 200:
{
"success": true,
"data": {
"id": "uuid",
"employee_id": "uuid",
"organization_id": "uuid",
"checked_in_at": "2026-03-10T08:00:00.000Z",
"checked_out_at": "2026-03-10T17:00:00.000Z",
"status": "CLOSED"
}
}Error 400: "Cannot check out: no active session found. Check in first." — thrown by SessionAlreadyClosed
Manually trigger an async distance/duration recalculation for a specific session. Useful after data corrections or for debugging.
Auth: Any authenticated user
Rate limit: 5 requests per 60 seconds per JWT sub
Path params:
| Param | Type | Required |
|---|---|---|
sessionId |
UUID | Yes |
Request body: None
Response 202:
{ "success": true, "queued": true }Error 404: Session not found or does not belong to the caller's organization.
List the caller's own attendance sessions, newest first.
Auth: Any authenticated user
Query params:
| Param | Type | Default | Constraints |
|---|---|---|---|
page |
integer | 1 |
min 1 |
limit |
integer | 20 |
min 1, max 100 |
Response 200:
{
"success": true,
"data": [
{
"id": "uuid",
"employee_id": "uuid",
"organization_id": "uuid",
"checked_in_at": "...",
"checked_out_at": "...",
"status": "CLOSED"
}
]
}List all attendance sessions for the caller's organization, newest first. ADMIN only.
Auth: JWT + role: "ADMIN"
Query params: Same as /attendance/my-sessions
Response 200: Same shape as my-sessions but includes sessions from all employees in the organization.
High-frequency GPS ingestion. Both write endpoints are rate-limited per JWT sub to prevent individual employees from flooding the ingestion pipeline, even when sharing an IP (e.g. corporate NAT).
Ingest a single GPS point for an active session.
Auth: JWT + role: "EMPLOYEE"
Rate limit: 10 requests per 10 seconds per JWT sub
Request body:
{
"session_id": "uuid",
"latitude": 28.6139,
"longitude": 77.2090,
"accuracy": 12.5,
"recorded_at": "2026-03-10T09:30:00.000Z"
}| Field | Type | Constraints |
|---|---|---|
session_id |
UUID | Must be a valid UUID |
latitude |
number | -90 to 90 |
longitude |
number | -180 to 180 |
accuracy |
number | ≥ 0 (metres) |
recorded_at |
ISO-8601 datetime | Must not be more than 2 minutes in the future |
Response 201:
{
"success": true,
"data": {
"id": "uuid",
"session_id": "uuid",
"organization_id": "uuid",
"latitude": 28.6139,
"longitude": 77.2090,
"accuracy": 12.5,
"recorded_at": "2026-03-10T09:30:00.000Z",
"sequence_number": null
}
}Note:
sequence_numberis nullable during mobile app stabilization. Distance calculations useORDER BY recorded_atas the primary ordering.
Ingest up to 100 GPS points in a single request. Duplicate points (same session_id + recorded_at) are silently ignored (upsert with ignoreDuplicates: true).
Auth: JWT + role: "EMPLOYEE"
Rate limit: 10 requests per 10 seconds per JWT sub
Request body:
{
"session_id": "uuid",
"points": [
{ "latitude": 28.6139, "longitude": 77.2090, "accuracy": 12.5, "recorded_at": "2026-03-10T09:30:00.000Z" },
{ "latitude": 28.6142, "longitude": 77.2094, "accuracy": 11.0, "recorded_at": "2026-03-10T09:30:30.000Z" }
]
}| Field | Constraints |
|---|---|
points |
Array, min 1, max 100 items |
| Each point | Same field constraints as single-insert, without session_id |
Response 201:
{
"success": true,
"inserted": 2
}
insertedmay be less thanpoints.lengthwhen duplicates are suppressed. The difference is logged asduplicatesSuppressed.
Retrieve all GPS points for a specific session belonging to the caller's organization, ordered by recorded_at ascending.
Auth: JWT + role: "EMPLOYEE"
Query params:
| Param | Type | Required |
|---|---|---|
sessionId |
UUID | Yes |
Response 200:
{
"success": true,
"data": [
{
"id": "uuid",
"session_id": "uuid",
"organization_id": "uuid",
"latitude": 28.6139,
"longitude": 77.2090,
"accuracy": 12.5,
"recorded_at": "...",
"sequence_number": null
}
]
}Submit a new expense claim. Created with status: "PENDING" pending admin review.
Auth: JWT + role: "EMPLOYEE"
Rate limit: 10 requests per 60 seconds per JWT sub
Request body:
{
"amount": 250.50,
"description": "Fuel for client visit",
"receipt_url": "https://storage.example.com/receipts/abc123.jpg"
}| Field | Type | Constraints |
|---|---|---|
amount |
number | Positive |
description |
string | 3–500 characters |
receipt_url |
string (URL) | Optional; must be a valid URL if provided |
Response 201:
{
"success": true,
"data": {
"id": "uuid",
"employee_id": "uuid",
"organization_id": "uuid",
"amount": 250.50,
"description": "Fuel for client visit",
"receipt_url": "https://...",
"status": "PENDING",
"created_at": "2026-03-10T10:00:00.000Z"
}
}List the caller's own expense submissions, newest first.
Auth: JWT + role: "EMPLOYEE"
Query params:
| Param | Type | Default | Constraints |
|---|---|---|---|
page |
integer | 1 |
min 1 |
limit |
integer | 20 |
min 1, max 100 |
Response 200:
{
"success": true,
"data": [ /* array of expense records */ ]
}List all expense submissions for the caller's organization, newest first. ADMIN only.
Auth: JWT + role: "ADMIN"
Query params: Same as GET /expenses/my
Approve or reject a pending expense. Only PENDING expenses can be acted on — attempting to update an already-reviewed expense returns a 400.
Auth: JWT + role: "ADMIN"
Path params:
| Param | Type | Required |
|---|---|---|
id |
UUID | Yes |
Request body:
{ "status": "APPROVED" }status |
Meaning |
|---|---|
"APPROVED" |
Marks expense as approved |
"REJECTED" |
Marks expense as rejected |
Response 200:
{
"success": true,
"data": { /* updated expense record */ }
}Error 400: "Expense has already been reviewed (current status: APPROVED)" — thrown by ExpenseAlreadyReviewed
Error 404: Expense not found or does not belong to the caller's organization.
All analytics endpoints require JWT + ADMIN role. EMPLOYEE tokens receive 403.
Organisation-wide aggregated totals for a given date range.
Auth: JWT + role: "ADMIN"
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
from |
ISO-8601 datetime | No | Range start (inclusive) |
to |
ISO-8601 datetime | No | Range end (inclusive) |
Response 200:
{
"success": true,
"data": {
"totalSessions": 142,
"totalDistanceKm": 4820.5,
"totalDurationSeconds": 1843200,
"totalExpenses": 38,
"approvedExpenseAmount": 9450.00,
"rejectedExpenseAmount": 550.00,
"activeEmployeesCount": 12
}
}Per-user totals and averages for a given date range.
Auth: JWT + role: "ADMIN"
Query params:
| Param | Type | Required |
|---|---|---|
userId |
UUID | Yes |
from |
ISO-8601 datetime | No |
to |
ISO-8601 datetime | No |
Response 200:
{
"success": true,
"data": {
"userId": "uuid",
"totalSessions": 22,
"totalDistanceKm": 741.3,
"totalDurationSeconds": 284400,
"avgDistanceKmPerSession": 33.7,
"avgDurationSecondsPerSession": 12927,
"totalExpenses": 6,
"approvedExpenseAmount": 1650.00
}
}Ranked leaderboard of employees sorted by a chosen metric.
Auth: JWT + role: "ADMIN"
Query params:
| Param | Type | Required | Notes |
|---|---|---|---|
metric |
"distance" | "duration" | "sessions" |
Yes | Ranking criterion |
from |
ISO-8601 datetime | No | |
to |
ISO-8601 datetime | No | |
limit |
integer | No | 1–50, default 10 |
Response 200:
{
"success": true,
"data": [
{
"employeeId": "uuid",
"totalDistanceKm": 741.3,
"totalDurationSeconds": 284400,
"totalSessions": 22
}
]
}Results are ordered descending by the chosen metric.
| Endpoint | Limit | Window | Key |
|---|---|---|---|
| All routes (global) | 100 req | 1 minute | IP |
POST /locations |
10 req | 10 seconds | JWT sub |
POST /locations/batch |
10 req | 10 seconds | JWT sub |
POST /expenses |
10 req | 60 seconds | JWT sub |
POST /attendance/:id/recalculate |
5 req | 60 seconds | JWT sub |
localhost / ::1 are exempt from all rate limits (health checks, monitoring scrapes).
When a rate limit is exceeded:
{
"success": false,
"error": "Too many requests",
"retryAfter": "42s"
}FieldTrack uses Supabase-issued JWTs. The backend validates the following claims with Zod:
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"organization_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"role": "EMPLOYEE",
"iat": 1741600000,
"exp": 1741686400
}The organization_id claim is attached to request.organizationId by the authenticate middleware and used by every repository method for tenant isolation. No cross-organization data access is possible via the API.