---
title: API Reference
description: Complete REST API reference for both the Express API (port 4000) and Fastify Media API (port 4100).
icon: material/api
tags:
  - reference
  - developer
  - API
---

# API Reference

Changemaker Lite exposes two REST APIs sharing a single PostgreSQL database.

| Server | Framework | Port | Purpose |
|--------|-----------|------|---------|
| **Main API** | Express.js | `4000` | Auth, campaigns, map, shifts, canvassing, pages, email, settings |
| **Media API** | Fastify | `4100` | Video library, analytics, playlists, reactions, comments |

Both APIs use **JWT Bearer authentication** and return JSON. All request/response bodies are `application/json` unless noted otherwise.

---

## Authentication

### Token Flow

```mermaid
sequenceDiagram
    participant Client
    participant API
    participant DB

    Client->>API: POST /api/auth/login {email, password}
    API->>DB: Verify credentials
    DB-->>API: User record
    API-->>Client: {accessToken, refreshToken}
    Note over Client: Store tokens

    Client->>API: GET /api/campaigns (Authorization: Bearer <accessToken>)
    API-->>Client: 200 OK

    Note over Client: Access token expires (15 min)

    Client->>API: POST /api/auth/refresh {refreshToken}
    API->>DB: Rotate token (atomic transaction)
    DB-->>API: New token pair
    API-->>Client: {accessToken, refreshToken}
```

### Headers

All authenticated requests require:

```http
Authorization: Bearer <accessToken>
```

The Media API also accepts tokens via query parameter for SSE streams:

```
GET /api/public/:id/chat-stream?token=<accessToken>
```

### Roles

| Role | Access Level |
|------|-------------|
| `SUPER_ADMIN` | Full platform access |
| `INFLUENCE_ADMIN` | Campaign and advocacy management |
| `MAP_ADMIN` | Map, locations, shifts, canvassing |
| `USER` | Volunteer portal, public features |
| `TEMP` | Limited access (auto-created on public shift signup) |

### Middleware Reference

| Middleware | Effect |
|-----------|--------|
| `authenticate` | Requires valid JWT. Sets `req.user` with `id`, `email`, `role`. Returns `401` if missing or invalid. |
| `optionalAuth` | Same as `authenticate` but continues without user if token is absent. |
| `requireRole(...roles)` | Checks user role against allowed list. Returns `403` if not authorized. |
| `requireNonTemp` | Blocks `TEMP` users. Returns `403`. |
| `validate(schema, source)` | Validates request body/query/params against a Zod schema. Returns `400` on failure. |

---

## Error Responses

All errors follow a consistent format:

```json
{
  "error": {
    "message": "Human-readable error description",
    "code": "ERROR_CODE",
    "statusCode": 400
  }
}
```

| Status | Code | Meaning |
|--------|------|---------|
| `400` | `VALIDATION_ERROR` | Request body/query failed schema validation |
| `401` | `UNAUTHORIZED` | Missing or invalid access token |
| `403` | `FORBIDDEN` | Valid token but insufficient role |
| `404` | `NOT_FOUND` | Resource does not exist |
| `429` | `RATE_LIMITED` | Too many requests (see Rate Limits) |
| `500` | `INTERNAL_ERROR` | Unexpected server error |

!!! note "Enumeration Prevention"
    Auth endpoints (`/login`, `/register`, `/forgot-password`) return generic success messages to prevent user enumeration. A `401` from `/api/auth/me` does **not** reveal whether the user exists.

---

## Rate Limits

Rate limits are Redis-backed and keyed by IP address.

| Endpoint Group | Window | Max Requests | Redis Prefix |
|---------------|--------|-------------|--------------|
| Auth (login, register, refresh) | 15 min | 10 | `rl:auth:` |
| Email sending | 1 hour | 30 | `rl:email:` |
| Response submission | 1 hour | 10 | `rl:response:` |
| Shift signup | 1 hour | 10 | `rl:shift-signup:` |
| Canvass visits | 1 min | 30 | `rl:canvass-visit:` |
| Canvass bulk visits | 1 min | 5 | `rl:canvass-visit-bulk:` |
| GPS tracking | 1 min | 6 | `rl:gps-tracking:` |
| Canvass geocode | 1 min | 10 | `rl:canvass-geocode:` |
| Observability | 1 min | 20 | `rl:observability:` |
| Health/metrics | 1 min | 30 | `rl:health-metrics:` |
| Global (all other) | Configurable | Configurable | `rl:global:` |

When rate-limited, the API returns:

```json
{
  "error": {
    "message": "Too many requests, please try again later",
    "code": "RATE_LIMITED",
    "statusCode": 429
  }
}
```

---

## Main API (Express &mdash; Port 4000)

### Health & Metrics

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/health` | :material-close: | Health check &mdash; PostgreSQL + Redis ping |
| GET | `/api/metrics` | :material-close: | Prometheus metrics (text/plain) |

??? example "Health response"
    ```json
    {
      "status": "healthy",
      "checks": {
        "database": "ok",
        "redis": "ok"
      }
    }
    ```

---

### Auth

**Prefix:** `/api/auth`

| Method | Path | Auth | Rate Limited | Description |
|--------|------|------|-------------|-------------|
| POST | `/api/auth/login` | :material-close: | :material-check: | Email + password login |
| POST | `/api/auth/register` | :material-close: | :material-check: | Create account (always `USER` role) |
| POST | `/api/auth/verify-email` | :material-close: | :material-check: | Verify email with token |
| POST | `/api/auth/resend-verification` | :material-close: | :material-check: | Resend verification email |
| POST | `/api/auth/forgot-password` | :material-close: | :material-check: | Send password reset email |
| POST | `/api/auth/reset-password` | :material-close: | :material-check: | Set new password with reset token |
| POST | `/api/auth/refresh` | :material-close: | :material-check: | Rotate refresh token &rarr; new token pair |
| POST | `/api/auth/logout` | :material-close: | :material-check: | Invalidate refresh token |
| GET | `/api/auth/me` | :material-check: | :material-close: | Current user profile |

??? example "Login request & response"
    **Request:**
    ```json
    {
      "email": "admin@example.com",
      "password": "SecurePass123!"
    }
    ```
    **Response:**
    ```json
    {
      "accessToken": "eyJhbG...",
      "refreshToken": "eyJhbG...",
      "user": {
        "id": "uuid",
        "email": "admin@example.com",
        "name": "Admin",
        "role": "SUPER_ADMIN"
      }
    }
    ```

!!! info "Password Policy"
    Passwords must be at least **12 characters** with at least one uppercase letter, one lowercase letter, and one digit.

---

### Users

**Prefix:** `/api/users` &middot; **Auth:** All routes require authentication

| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/api/users` | Admin | Paginated user list with search, role, and status filters |
| GET | `/api/users/:id` | Admin or self | Single user profile |
| POST | `/api/users` | Admin | Create user |
| PUT | `/api/users/:id` | Admin or self | Update user (non-admins cannot change role/status) |
| POST | `/api/users/:id/approve` | Admin | Approve pending user; sends approval email |
| POST | `/api/users/:id/reject` | Admin | Reject pending user |
| DELETE | `/api/users/:id` | Admin | Delete user |

**Query parameters for `GET /api/users`:**

| Param | Type | Description |
|-------|------|-------------|
| `page` | number | Page number (default 1) |
| `limit` | number | Items per page (default 20) |
| `search` | string | Search by name or email |
| `role` | string | Filter by role |
| `status` | string | Filter by status |

---

### Dashboard

**Prefix:** `/api/dashboard` &middot; **Auth:** Admin roles required

| Method | Path | Role | Description |
|--------|------|------|-------------|
| GET | `/api/dashboard/summary` | Any admin | Platform-wide counts (users, campaigns, locations, shifts) |
| GET | `/api/dashboard/system` | `SUPER_ADMIN` | Hardware + OS info (CPU, memory, disk) |
| GET | `/api/dashboard/containers` | `SUPER_ADMIN` | Docker container statuses |
| GET | `/api/dashboard/weather` | Any admin | Current weather at map center coordinates |
| GET | `/api/dashboard/api-metrics` | `SUPER_ADMIN` | Prometheus API performance metrics |
| GET | `/api/dashboard/time-series` | `SUPER_ADMIN` | Prometheus time-series data |
| GET | `/api/dashboard/container-resources` | `SUPER_ADMIN` | cAdvisor CPU/memory/network per container |

**Query parameters for `GET /api/dashboard/time-series`:**

| Param | Type | Description |
|-------|------|-------------|
| `metrics` | string | Comma-separated metric keys (whitelist-validated) |
| `range` | string | Time range (e.g., `1h`, `24h`, `7d`) |
| `step` | string | Sample interval (e.g., `5m`, `1h`) |

---

### Campaigns

#### Admin CRUD

**Prefix:** `/api/campaigns` &middot; **Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/campaigns` | Paginated campaign list |
| GET | `/api/campaigns/:id` | Single campaign detail |
| POST | `/api/campaigns` | Create campaign |
| PUT | `/api/campaigns/:id` | Update campaign |
| DELETE | `/api/campaigns/:id` | Delete campaign |

#### Public

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/campaigns/public` | :material-close: | List all active campaigns |
| GET | `/api/campaigns/:slug/details` | :material-close: | Campaign detail by slug (ACTIVE only) |

#### User Submissions

**Auth:** Authenticated, non-TEMP users

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/campaigns/user/submit` | Submit campaign for moderation (5/hour limit) |
| GET | `/api/campaigns/user/my-campaigns` | List own submitted campaigns |
| PUT | `/api/campaigns/user/:id` | Edit own pending campaign |

#### Moderation

**Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/campaigns/moderation/queue` | Campaigns pending moderation |
| GET | `/api/campaigns/moderation/stats` | Moderation queue statistics |
| PATCH | `/api/campaigns/moderation/:id` | Approve or reject campaign |

#### Campaign Emails

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/campaigns/:slug/send-email` | :material-close: | Send advocacy email to representatives (rate limited: 30/hour) |
| POST | `/api/campaigns/:slug/track-mailto` | :material-close: | Track mailto link click |
| GET | `/api/campaigns/:id/emails` | Admin | Paginated emails for campaign |
| GET | `/api/campaigns/:id/email-stats` | Admin | Email statistics |

---

### Responses

**Prefix:** `/api/campaigns` (public) and `/api/responses` (admin + actions)

#### Public

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/campaigns/:slug/responses` | :material-close: | List approved public responses |
| GET | `/api/campaigns/:slug/response-stats` | :material-close: | Response statistics |
| POST | `/api/campaigns/:slug/responses` | :material-close: | Submit response (rate limited: 10/hour) |
| POST | `/api/responses/:id/upvote` | Optional | Upvote a response |
| DELETE | `/api/responses/:id/upvote` | Optional | Remove upvote |
| GET | `/api/responses/:id/verify/:token` | :material-close: | Verify response via email link |

#### Admin

**Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/responses` | All responses with filters |
| PATCH | `/api/responses/:id/status` | Approve or reject response |
| POST | `/api/responses/:id/resend-verification` | Resend verification email |
| DELETE | `/api/responses/:id` | Delete response |

---

### Representatives

**Prefix:** `/api/representatives`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/representatives/by-postal/:postalCode` | :material-close: | Lookup representatives by postal code (cache-first) |
| GET | `/api/representatives/test-connection` | :material-close: | Represent API health check |
| GET | `/api/representatives/cache-stats` | Admin | Cache statistics |
| GET | `/api/representatives` | Admin | Paginated cached representatives |
| GET | `/api/representatives/:id` | Admin | Single cached representative |
| DELETE | `/api/representatives/by-postal/:postalCode` | Admin | Clear cache for postal code |
| DELETE | `/api/representatives/:id` | Admin | Delete cached representative |

**Query parameters for postal code lookup:**

| Param | Type | Description |
|-------|------|-------------|
| `refresh` | boolean | Force API call, bypass cache |

---

### Email Queue

**Prefix:** `/api/email-queue` &middot; **Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/email-queue/stats` | BullMQ queue statistics (waiting, active, completed, failed) |
| POST | `/api/email-queue/pause` | Pause email processing |
| POST | `/api/email-queue/resume` | Resume email processing |
| POST | `/api/email-queue/clean` | Clean completed jobs |

---

### Locations

**Prefix:** `/api/map/locations`

#### Public

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/locations/public` | All geocoded locations for map (no PII); optional `?bounds=` |

#### Admin

**Auth:** `SUPER_ADMIN` or `MAP_ADMIN`

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/locations` | Paginated locations with filters |
| GET | `/api/map/locations/stats` | Location statistics |
| GET | `/api/map/locations/all` | All geocoded locations for admin map |
| GET | `/api/map/locations/export-csv` | CSV export |
| GET | `/api/map/locations/:id` | Single location |
| GET | `/api/map/locations/:id/history` | Edit history |
| POST | `/api/map/locations` | Create location |
| PUT | `/api/map/locations/:id` | Update location |
| DELETE | `/api/map/locations/:id` | Delete location |
| POST | `/api/map/locations/bulk-delete` | Bulk delete |
| POST | `/api/map/locations/geocode` | Geocode single address |
| POST | `/api/map/locations/geocode-missing` | Batch geocode all ungeocoded |
| POST | `/api/map/locations/reverse-geocode` | Reverse geocode lat/lng to address |
| POST | `/api/map/locations/import-csv` | Import from CSV (10 MB limit) |
| POST | `/api/map/locations/import-bulk` | Bulk NAR or standard CSV import (100 MB limit) |

#### Bulk Geocode

**Prefix:** `/api/map/locations/bulk-geocode` &middot; **Auth:** Map admins

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/map/locations/bulk-geocode` | Start BullMQ bulk geocoding job |
| GET | `/api/map/locations/bulk-geocode/:jobId` | Poll job status |
| GET | `/api/map/locations/bulk-geocode/stats` | Queue statistics |

#### NAR Import

**Prefix:** `/api/map/nar-import` &middot; **Auth:** Map admins

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/nar-import/datasets` | Available NAR datasets by province |
| POST | `/api/map/nar-import` | Start province import (fire-and-forget) |
| GET | `/api/map/nar-import/status/:importId` | Poll import progress |

??? info "NAR Import body"
    ```json
    {
      "provinceCode": "24",
      "filterType": "city",
      "filterCity": "Edmonton",
      "residentialOnly": true,
      "deduplicateRadius": 10,
      "batchSize": 500
    }
    ```

#### Area Import

**Prefix:** `/api/map/area-import` &middot; **Auth:** Map admins

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/map/area-import/preview` | Preview bounds + estimated record counts |
| POST | `/api/map/area-import` | Start area import (fire-and-forget) |
| GET | `/api/map/area-import/status/:importId` | Poll import progress |

---

### Cuts (Polygons)

**Prefix:** `/api/map/cuts`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/map/cuts/public` | :material-close: | All public cuts as GeoJSON |
| GET | `/api/map/cuts` | Map admin | Paginated cuts list |
| GET | `/api/map/cuts/:id` | Map admin | Single cut |
| POST | `/api/map/cuts` | Map admin | Create cut (polygon GeoJSON) |
| PUT | `/api/map/cuts/:id` | Map admin | Update cut |
| DELETE | `/api/map/cuts/:id` | Map admin | Delete cut |
| GET | `/api/map/cuts/:id/locations` | Map admin | All locations within cut polygon |
| GET | `/api/map/cuts/:id/statistics` | Map admin | Support level breakdown |
| GET | `/api/map/cuts/export-geojson` | Map admin | All cuts as GeoJSON FeatureCollection |
| GET | `/api/map/cuts/:id/export-geojson` | Map admin | Single cut as GeoJSON Feature |
| POST | `/api/map/cuts/import-geojson` | Map admin | Import cuts from GeoJSON file |

---

### Shifts

**Prefix:** `/api/map/shifts`

#### Public

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/shifts/public` | List upcoming public shifts |
| POST | `/api/map/shifts/public/:id/signup` | Public signup (creates TEMP user if needed; rate limited: 10/hour) |

#### Volunteer

**Auth:** Any authenticated user

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/shifts/volunteer/upcoming` | Upcoming shifts with signup status |
| GET | `/api/map/shifts/volunteer/my-signups` | Own confirmed signups |
| POST | `/api/map/shifts/volunteer/:id/signup` | Sign up for shift |
| DELETE | `/api/map/shifts/volunteer/:id/signup` | Cancel signup |

#### Admin

**Auth:** Map admins

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/shifts` | Paginated shifts with filters |
| GET | `/api/map/shifts/stats` | Statistics |
| GET | `/api/map/shifts/calendar` | Calendar data (`?startDate=&endDate=`) |
| GET | `/api/map/shifts/:id` | Single shift with signups |
| POST | `/api/map/shifts` | Create shift |
| PUT | `/api/map/shifts/:id` | Update shift |
| DELETE | `/api/map/shifts/:id` | Delete shift |
| POST | `/api/map/shifts/:id/signups` | Admin-add volunteer |
| DELETE | `/api/map/shifts/:id/signups/:signupId` | Remove volunteer |
| POST | `/api/map/shifts/:id/email-details` | Email details to all volunteers |

#### Shift Series

**Auth:** Map admins

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/map/shifts/series` | Create recurring shift series |
| GET | `/api/map/shifts/series/:id` | Get series |
| PUT | `/api/map/shifts/series/:id` | Update series |
| DELETE | `/api/map/shifts/series/:id` | Delete series |

---

### Canvassing

**Prefix:** `/api/map/canvass`

#### Volunteer

**Auth:** Any authenticated user

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/canvass/my/assignments` | Shift assignments |
| GET | `/api/map/canvass/my/stats` | Personal canvass statistics |
| GET | `/api/map/canvass/my/visits` | Visit history |
| GET | `/api/map/canvass/my/session` | Active canvass session |
| POST | `/api/map/canvass/sessions` | Start canvass session |
| POST | `/api/map/canvass/sessions/:id/end` | End session |
| GET | `/api/map/canvass/cuts/:cutId/locations` | Locations in cut with visit annotations |
| GET | `/api/map/canvass/cuts/:cutId/route` | Walking route algorithm for cut |
| GET | `/api/map/canvass/locations` | All locations with visit annotations |
| PUT | `/api/map/canvass/locations/:id` | Edit address (role-gated fields) |
| POST | `/api/map/canvass/locations` | Create location |
| POST | `/api/map/canvass/reverse-geocode` | Reverse geocode lat/lng |
| POST | `/api/map/canvass/geocode-search` | Geocode address for map (rate limited: 10/min) |
| POST | `/api/map/canvass/visits` | Record door knock (rate limited: 30/min) |
| POST | `/api/map/canvass/visits/bulk` | Record visit for all unvisited units (rate limited: 5/min) |

#### Admin

**Auth:** `SUPER_ADMIN` or `MAP_ADMIN`

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/canvass/stats` | Platform-wide canvass statistics |
| GET | `/api/map/canvass/stats/cuts/:cutId` | Statistics for specific cut |
| GET | `/api/map/canvass/activity` | Recent activity feed |
| GET | `/api/map/canvass/volunteers` | All volunteers with canvass activity |
| GET | `/api/map/canvass/volunteers/:userId` | Individual volunteer statistics |
| GET | `/api/map/canvass/visits` | All visits with filters |

---

### GPS Tracking

**Prefix:** `/api/map/tracking`

#### Volunteer

**Auth:** Any authenticated user

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/map/tracking/sessions` | Start GPS tracking session |
| POST | `/api/map/tracking/sessions/:id/end` | End tracking session |
| POST | `/api/map/tracking/sessions/:id/points` | Submit GPS point batch (rate limited: 6/min) |
| POST | `/api/map/tracking/sessions/:id/link-canvass` | Link to canvass session |
| GET | `/api/map/tracking/my/session` | Active tracking session |
| GET | `/api/map/tracking/my/sessions` | Own historical sessions |
| GET | `/api/map/tracking/my/sessions/:id/route` | Full route for own session |

#### Admin

**Auth:** Map admins

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/tracking/live` | Live volunteer positions + trails |
| GET | `/api/map/tracking/sessions` | All historical tracking sessions |
| GET | `/api/map/tracking/sessions/:id/route` | Full route for any session |

---

### Map Settings

**Prefix:** `/api/map/settings`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/map/settings` | :material-close: | Public map settings (center, zoom, walk sheet config) |
| PUT | `/api/map/settings` | Map admin | Update map settings |

---

### Geocoding

**Prefix:** `/api/map/geocoding` &middot; **Auth:** Map admins

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/geocoding/search` | Geocode address search (`?q=&limit=1-10`) |

---

### Landing Pages

**Prefix:** `/api/pages` and `/api/page-blocks`

#### Public

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/pages/:slug/view` | :material-close: | Get published page by slug |

#### Admin

**Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/pages` | Paginated landing pages |
| GET | `/api/pages/:id` | Single page |
| POST | `/api/pages` | Create page |
| PUT | `/api/pages/:id` | Update page |
| DELETE | `/api/pages/:id` | Delete page |
| POST | `/api/pages/sync` | Sync MkDocs overrides from filesystem |
| POST | `/api/pages/validate` | Validate and repair MkDocs exports |

#### Block Library

**Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/page-blocks` | List blocks |
| GET | `/api/page-blocks/:id` | Single block |
| POST | `/api/page-blocks` | Create block |
| PUT | `/api/page-blocks/:id` | Update block |
| DELETE | `/api/page-blocks/:id` | Delete block |

---

### Email Templates

**Prefix:** `/api/email-templates` &middot; **Auth:** Admin roles (seed/cache require `SUPER_ADMIN`)

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/email-templates` | List templates |
| GET | `/api/email-templates/:id` | Single template |
| POST | `/api/email-templates` | Create template |
| PUT | `/api/email-templates/:id` | Update template |
| DELETE | `/api/email-templates/:id` | Delete template |
| GET | `/api/email-templates/:id/versions` | Version history |
| GET | `/api/email-templates/:id/versions/:versionNumber` | Specific version |
| POST | `/api/email-templates/:id/rollback` | Rollback to prior version |
| POST | `/api/email-templates/validate` | Validate Handlebars syntax |
| POST | `/api/email-templates/:id/test` | Send test email (rate limited: 10/15min) |
| GET | `/api/email-templates/:id/test-logs` | Test send logs |
| POST | `/api/email-templates/seed` | Seed templates from filesystem |
| POST | `/api/email-templates/clear-cache` | Clear template cache |

---

### QR Codes

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/qr` | :material-close: | Generate QR code PNG (`?text=&size=50-500`) |

Cached for 1 hour. Returns `image/png`.

---

### Site Settings

**Prefix:** `/api/settings`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/settings` | :material-close: | Public site settings (SMTP credentials stripped) |
| GET | `/api/settings/admin` | `SUPER_ADMIN` | Full settings including SMTP credentials |
| PUT | `/api/settings` | `SUPER_ADMIN` | Update settings |
| POST | `/api/settings/email/test-connection` | `SUPER_ADMIN` | Test SMTP connection |
| POST | `/api/settings/email/test-send` | `SUPER_ADMIN` | Send test email |

---

### Listmonk (Newsletter Sync)

**Prefix:** `/api/listmonk` &middot; **Auth:** `SUPER_ADMIN`

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/listmonk` | Sync status + connection check |
| GET | `/api/listmonk/stats` | Subscriber counts from Listmonk |
| POST | `/api/listmonk/test-connection` | Health check |
| POST | `/api/listmonk/sync/participants` | Sync campaign participants |
| POST | `/api/listmonk/sync/locations` | Sync locations |
| POST | `/api/listmonk/sync/users` | Sync users |
| POST | `/api/listmonk/sync/all` | Run all sync operations |
| POST | `/api/listmonk/reinitialize` | Reinitialize Listmonk lists |
| GET | `/api/listmonk/proxy-url` | Proxy port + JWT for iframe |

---

### Documentation Management

**Prefix:** `/api/docs` &middot; **Auth:** Authenticated, non-TEMP (write operations require `SUPER_ADMIN`)

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/docs/status` | MkDocs + Code Server availability |
| GET | `/api/docs/config` | Port numbers for iframe URLs |
| GET | `/api/docs/mkdocs-config` | Read raw `mkdocs.yml` |
| PUT | `/api/docs/mkdocs-config` | Write `mkdocs.yml` |
| POST | `/api/docs/build` | Trigger MkDocs build |
| POST | `/api/docs/upload` | Upload asset (20 MB, whitelisted extensions) |
| GET | `/api/docs/files` | File tree (`?force=true` bypasses cache) |
| POST | `/api/docs/files/rename` | Rename or move file |
| GET | `/api/docs/files/*` | Read file content |
| PUT | `/api/docs/files/*` | Write file content |
| POST | `/api/docs/files/*` | Create file or folder |
| DELETE | `/api/docs/files/*` | Delete file or empty folder |

---

### Services

**Prefix:** `/api/services` &middot; **Auth:** `SUPER_ADMIN`

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/services/status` | Health check all managed services (NocoDB, n8n, Gitea, MailHog, Mini QR, Excalidraw, Homepage) |
| GET | `/api/services/config` | Port numbers + subdomain info |

---

### Pangolin (Tunnel Management)

**Prefix:** `/api/pangolin` &middot; **Auth:** `SUPER_ADMIN`

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/pangolin/status` | Tunnel health + connection info |
| GET | `/api/pangolin/config` | Current env configuration |
| GET | `/api/pangolin/newt-status` | Newt container status |
| POST | `/api/pangolin/newt-restart` | Restart Newt container |
| GET | `/api/pangolin/sites` | List Pangolin sites |
| GET | `/api/pangolin/exit-nodes` | Available exit nodes |
| GET | `/api/pangolin/resource-definitions` | Resource definitions from YAML |
| GET | `/api/pangolin/resources` | List resources |
| POST | `/api/pangolin/setup` | Create site + all resources (rate limited: 3/5min) |
| POST | `/api/pangolin/sync` | Sync resources (create missing, update changed) |
| PUT | `/api/pangolin/resource/:id` | Update resource |
| DELETE | `/api/pangolin/resource/:id` | Delete resource |
| GET | `/api/pangolin/resource/:id/clients` | Connected clients |
| GET | `/api/pangolin/certificate/:domainId/:domain` | Certificate info |
| POST | `/api/pangolin/certificate/:certId` | Update certificate |

---

### Observability

**Prefix:** `/api/observability` &middot; **Auth:** `SUPER_ADMIN` &middot; **Rate limited:** 20/min

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/observability/status` | Check 7 monitoring services |
| GET | `/api/observability/metrics-summary` | Key metrics from Prometheus |
| GET | `/api/observability/alerts` | Active alerts from Alertmanager |

---

### Payments

**Prefix:** `/api/payments`

#### Public

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/payments/config` | :material-close: | Stripe publishable key + donation settings |
| GET | `/api/payments/plans` | :material-close: | Active subscription plans |
| GET | `/api/payments/products` | :material-close: | Active products (`?type=`) |
| POST | `/api/payments/subscribe` | :material-check: | Create subscription checkout |
| POST | `/api/payments/purchase` | Optional | Product checkout (guest or logged-in) |
| POST | `/api/payments/donate` | :material-close: | Donation checkout |
| GET | `/api/payments/my-subscription` | :material-check: | Current subscription |
| POST | `/api/payments/my-subscription/cancel` | :material-check: | Cancel subscription |
| POST | `/api/payments/webhook` | :material-close: | Stripe webhook (raw body) |

#### Admin

**Auth:** `SUPER_ADMIN`

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/payments/admin/settings` | Payment settings (secrets masked) |
| PUT | `/api/payments/admin/settings` | Update payment settings |
| POST | `/api/payments/admin/settings/test-connection` | Test Stripe connection |
| GET | `/api/payments/admin/dashboard` | Subscription + donation statistics |
| GET | `/api/payments/admin/plans` | All subscription plans |
| POST | `/api/payments/admin/plans` | Create plan |
| PUT | `/api/payments/admin/plans/:id` | Update plan |
| DELETE | `/api/payments/admin/plans/:id` | Delete plan |
| POST | `/api/payments/admin/plans/:id/sync-stripe` | Sync plan to Stripe |
| GET | `/api/payments/admin/subscriptions` | All subscriptions with filters |
| POST | `/api/payments/admin/subscriptions/:id/cancel` | Cancel subscription |
| GET | `/api/payments/admin/products` | All products |
| POST | `/api/payments/admin/products` | Create product |
| PUT | `/api/payments/admin/products/:id` | Update product |
| DELETE | `/api/payments/admin/products/:id` | Delete product |
| POST | `/api/payments/admin/products/:id/sync-stripe` | Sync product to Stripe |
| GET | `/api/payments/admin/orders` | List orders |
| POST | `/api/payments/admin/orders/:id/refund` | Refund order |
| GET | `/api/payments/admin/donations` | List donations |
| GET | `/api/payments/admin/export` | CSV export of completed orders |

---

## Media API (Fastify &mdash; Port 4100)

The Media API is a separate Fastify server sharing the same PostgreSQL database. It handles all video-related functionality.

### Health

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | :material-close: | Media API health check |

---

### Videos (Admin)

**Prefix:** `/api/videos` &middot; **Auth:** Admin roles

#### CRUD & Publishing

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/videos` | List videos (`?limit=&offset=&search=&orientation=&producers=&isShort=`) |
| GET | `/api/videos/producers` | Distinct producer list |
| GET | `/api/videos/health` | Video count health check |
| GET | `/api/videos/:id` | Single video detail |
| PATCH | `/api/videos/:id` | Update metadata (title, producer, tags, quality, etc.) |
| POST | `/api/videos/:id/publish` | Publish to category |
| POST | `/api/videos/:id/unpublish` | Unpublish |
| POST | `/api/videos/bulk-publish` | Bulk publish |
| POST | `/api/videos/bulk-unpublish` | Bulk unpublish |
| POST | `/api/videos/:id/lock` | Lock published video |
| POST | `/api/videos/:id/unlock` | Unlock video |
| POST | `/api/videos/:id/generate-thumbnail` | Generate thumbnail via FFmpeg |
| POST | `/api/videos/bulk-generate-thumbnails` | Bulk thumbnail generation |

#### Upload

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/videos/upload` | Single video upload (multipart, 10 GB limit, streams to disk) |
| POST | `/api/videos/upload/batch` | Batch upload (returns 207 multi-status) |

#### Actions

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/videos/:id/duplicate` | Duplicate video record |
| POST | `/api/videos/:id/replace` | Replace video file, keep metadata |
| GET | `/api/videos/:id/analytics` | Detailed analytics (`?startDate=&endDate=`) |
| POST | `/api/videos/:id/reset-analytics` | Reset all analytics |
| GET | `/api/videos/:id/preview-link` | Generate 24-hour JWT preview link |
| GET | `/api/videos/analytics/top` | Top videos (`?metric=views|watchTime&limit=`) |
| GET | `/api/videos/analytics/overview` | Global analytics overview |

#### Scheduling

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/videos/:id/schedule-publish` | Schedule future publish (`{publishAt, timezone?}`) |
| POST | `/api/videos/:id/schedule-unpublish` | Schedule future unpublish |
| DELETE | `/api/videos/:id/schedule/:action` | Cancel scheduled operation |
| GET | `/api/videos/schedules/upcoming` | Upcoming scheduled operations |
| GET | `/api/videos/:id/schedule-history` | Schedule history for video |
| GET | `/api/videos/schedules/stats` | Schedule queue statistics |
| POST | `/api/videos/schedules/pause` | Pause schedule queue |
| POST | `/api/videos/schedules/resume` | Resume schedule queue |
| POST | `/api/videos/schedules/cleanup` | Clean old completed jobs |

#### Video Fetch

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/videos/fetch` | Submit fetch job (`{urls: string[]}`, 1&ndash;20 URLs) |
| GET | `/api/videos/fetch/jobs` | List recent fetch jobs |
| GET | `/api/videos/fetch/jobs/:jobId` | Job detail + log |
| GET | `/api/videos/fetch/jobs/:jobId/log` | SSE log stream (Redis pub/sub) |
| DELETE | `/api/videos/fetch/jobs/:jobId` | Cancel fetch job |

---

### Streaming (Public)

**Prefix:** `/api/videos`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/videos/stream/health` | :material-close: | Streaming health check |
| GET | `/api/videos/:id/stream` | Optional | HTTP range-supporting video stream |
| GET | `/api/videos/:id/thumbnail` | Optional | Serve thumbnail image |
| GET | `/api/videos/:id/metadata` | :material-close: | Public video metadata for embedding |

!!! note
    Admins can stream unpublished videos by providing a valid JWT.

---

### Public Gallery

**Prefix:** `/api/public`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/public` | Optional | Published videos (`?limit=&offset=&search=&sort=recent|popular|oldest&category=`) |
| GET | `/api/public/categories` | Optional | Categories with video counts |
| GET | `/api/public/producers` | Optional | Published producers |
| GET | `/api/public/:id` | Optional | Single published video |
| GET | `/api/public/:id/thumbnail` | Optional | Published thumbnail |
| GET | `/api/public/:id/stream` | Optional | Published video stream |

---

### Tracking

**Prefix:** `/api/track` &middot; **Auth:** None required

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/track/health` | Tracking health check |
| POST | `/api/track/view` | Record video view (returns `{viewId}`) |
| POST | `/api/track/event` | Record play/pause/seek/complete event |
| POST | `/api/track/heartbeat` | Update watch time (10s interval, `sendBeacon`) |
| POST | `/api/track/batch` | Batch up to 50 tracking events |

??? info "Tracking is GDPR-compliant"
    IP addresses are hashed with a daily-rotating salt. Raw IPs are never stored. Tracking data is retained for 90 days.

---

### Reactions

**Prefix:** `/api/reactions`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/reactions/config` | :material-close: | Available reaction types + emoji mappings |
| GET | `/api/reactions` | :material-close: | List reactions (`?mediaId=&userId=&limit=`) |
| GET | `/api/reactions/:mediaId/chat` | :material-close: | Reactions in chat timeline format |
| POST | `/api/reactions` | :material-check: | Add reaction (30s cooldown per type) |

Available types: `like`, `love`, `laugh`, `wow`, `sad`, `angry`

---

### Comments & Chat

#### Public Comments

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/public/:id/comments` | :material-close: | List comments (`?limit=&offset=`) |
| POST | `/api/public/:id/comments` | Optional | Create comment (word-filtered; rate limited: 5/min) |
| GET | `/api/public/:id/chat-stream` | :material-close: | SSE stream for real-time chat (30s keepalive) |

#### Comment Admin

**Prefix:** `/api/media/admin/comments` &middot; **Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/media/admin/comments/stats` | Counts by status |
| GET | `/api/media/admin/comments` | All comments with filters |
| PATCH | `/api/media/admin/comments/:id/approve` | Approve comment |
| PATCH | `/api/media/admin/comments/:id/hide` | Hide comment |
| PATCH | `/api/media/admin/comments/:id/unhide` | Unhide comment |
| PUT | `/api/media/admin/comments/:id/notes` | Update moderation notes |
| DELETE | `/api/media/admin/comments/:id` | Delete comment |

#### Word Filters

**Prefix:** `/api/media/admin/word-filters` &middot; **Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/media/admin/word-filters` | List filter entries grouped by level |
| POST | `/api/media/admin/word-filters` | Add word (`{word, level: low|medium|high|custom}`) |
| DELETE | `/api/media/admin/word-filters/:id` | Remove word |

#### Chat Threads & Notifications

**Auth:** Authenticated

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/media/chat/threads` | Videos with user's comments + unread counts |
| POST | `/api/media/chat/threads/:mediaId/read` | Mark thread as read |
| GET | `/api/media/notifications/stream` | Per-user SSE notification stream (`?token=`) |

---

### Shorts

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/shorts` | Optional | Shorts feed (`?sort=recent|popular|random`) |
| POST | `/api/shorts/scan` | Admin | Auto-classify short videos by duration |

---

### Upvotes

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/public/:id/upvote` | :material-close: | Toggle upvote (session-based via `X-Session-ID` header) |
| GET | `/api/public/:id/upvote-status` | :material-close: | Check upvote status for current session |

---

### Playlists

#### Public

**Prefix:** `/api/playlists`

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/playlists/featured` | Optional | Featured playlists |
| GET | `/api/playlists/popular` | Optional | Popular public playlists (`?search=`) |
| GET | `/api/playlists/share/:token` | Optional | Playlist by share token |
| GET | `/api/playlists/:id` | Optional | Playlist detail (public, owner, or share token) |
| POST | `/api/playlists/:id/view` | Optional | Record playlist view |

#### User Playlists

**Auth:** Authenticated

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/playlists/my` | Own playlists |
| POST | `/api/playlists` | Create playlist |
| PUT | `/api/playlists/:id` | Update playlist (ownership check) |
| DELETE | `/api/playlists/:id` | Delete playlist |
| POST | `/api/playlists/:id/videos` | Add video (`{mediaId}`) |
| DELETE | `/api/playlists/:id/videos/:mediaId` | Remove video |
| PUT | `/api/playlists/:id/videos/reorder` | Reorder videos |
| POST | `/api/playlists/:id/share` | Generate share token |
| DELETE | `/api/playlists/:id/share` | Revoke share token |

#### Playlist Admin

**Prefix:** `/api/media/playlists` &middot; **Auth:** Admin roles

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/media/playlists` | All playlists |
| GET | `/api/media/playlists/featured` | Featured playlists with admin info |
| POST | `/api/media/playlists/:id/feature` | Feature a playlist |
| DELETE | `/api/media/playlists/:id/feature` | Unfeature a playlist |
| PUT | `/api/media/playlists/featured/reorder` | Reorder featured playlists |
| PUT | `/api/media/playlists/:id` | Admin update any playlist |
| POST | `/api/media/playlists/:id/duplicate` | Duplicate playlist |
| DELETE | `/api/media/playlists/:id` | Admin delete any playlist |

---

### User Profile

**Prefix:** `/api/media/me` &middot; **Auth:** Authenticated

| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/media/me/stats` | User stats + 30-day activity + achievements |
| GET | `/api/media/me/watch-history` | Paginated watch history |
| POST | `/api/media/me/stats/recalculate` | Recompute stats from raw data |
| GET | `/api/media/me/settings` | Privacy settings |
| PUT | `/api/media/me/settings` | Update privacy settings |
| PUT | `/api/media/me/profile` | Update display name |
| PUT | `/api/media/me/password` | Change password |

---

## Route Summary

| API | Module | Endpoint Count |
|-----|--------|:---:|
| **Express** | Auth | 9 |
| | Users | 7 |
| | Dashboard | 7 |
| | Campaigns (CRUD + public + user + moderation + emails) | 16 |
| | Responses | 10 |
| | Email Queue | 4 |
| | Representatives | 7 |
| | Locations (CRUD + geocode + import) | 21 |
| | Cuts | 11 |
| | Shifts (CRUD + series) | 19 |
| | Canvassing | 20 |
| | GPS Tracking | 10 |
| | Map Settings + Geocoding | 3 |
| | Pages + Blocks | 12 |
| | Email Templates | 13 |
| | QR Codes | 1 |
| | Site Settings | 5 |
| | Listmonk | 9 |
| | Docs Management | 11 |
| | Services | 2 |
| | Pangolin | 16 |
| | Observability | 3 |
| | Payments (public + admin) | 29 |
| | Health + Metrics | 3 |
| **Express Total** | | **~248** |
| **Fastify** | Videos (CRUD + upload + actions + schedule + fetch) | 39 |
| | Streaming | 4 |
| | Public Gallery | 6 |
| | Tracking | 5 |
| | Reactions | 4 |
| | Comments + Chat | 13 |
| | Shorts + Upvotes | 4 |
| | Playlists (public + user + admin) | 18 |
| | User Profile | 7 |
| | Health | 1 |
| **Fastify Total** | | **~101** |
| **Grand Total** | | **~349** |
