A premium, high-performance, open-source dashboard and ingestion engine for your WHOOP fitness data. Built for homelabs, NAS devices, and cloud deployments.
This project supports two mutually exclusive data ingestion modes, letting it run in any environment.
For users behind NAT/firewalls without a public domain. The backend makes outbound requests to WHOOP on configurable intervals — zero open inbound ports required. Uses cursor-based pagination to sync your entire history automatically.
For public-facing cloud instances. WHOOP pushes events in real-time. We use a store-then-process inbox pattern: acknowledge instantly, persist to a queue, and process asynchronously — ensuring 100% data integrity even when the API is slow.
See design.md for the full architecture diagram and detailed technical justifications.
- Complete Data Coverage — Cycles, sleep stages, recoveries (SpO2, HRV, skin temp), workouts (HR zones), user profiles, and body measurements
- Continuous Aggregates — Pre-computed TimescaleDB views for daily/weekly strain, recovery, and sleep metrics. Auto-refreshed hourly.
- Linear-Inspired Dashboard — Dark glassmorphism UI with interactive Recharts visualizations, skeleton loading states, and error recovery
- End-to-End Type Safety —
sqlc(Go ↔ SQL) +openapi-typescript(Go ↔ TypeScript) - Security Hardened — AES-256-GCM token encryption, JWT with HS256 enforcement, per-IP rate limiting, non-root containers
- SSD Wear Protection — RAM-backed logs (
tmpfs), dynamic log levels, compressed binary logging - Tested — 33+ unit tests covering crypto, auth, rate limiting, config validation, and timezone parsing. Integration tests with testcontainers for database upserts.
The setup wizard handles everything — secret generation, WHOOP API credentials, OAuth tokens, and user ID detection:
git clone https://github.com/arvarik/whoop-stats.git
cd whoop-stats
./setup.shThe wizard will:
- ✅ Create
.envfrom the template - ✅ Auto-generate
ENCRYPTION_KEYandPOSTGRES_PASSWORD - ✅ Ask for your WHOOP Client ID and Secret (from developer.whoop.com)
- ✅ Run the OAuth flow and detect your WHOOP User ID automatically
- ✅ Validate everything is ready
Then deploy:
# Homelab / NAS (recommended)
docker compose up -d --build
# Production (named volumes, networks)
docker compose -f docker-compose.prod.yml up -d --buildDashboard: http://your-server:3032 · API: http://your-server:8085
| Requirement | Version | Notes |
|---|---|---|
| Docker + Docker Compose | v2+ | Required for all deployments |
| Go | 1.25+ | One-time use for the OAuth token generation |
| WHOOP Developer Account | — | Register here |
Deploying to a remote server? Run
./setup.shlocally (needs Go + browser), then copy.envand.whoop_token.jsonto your server.
Manual Setup (without setup.sh)
cp .env.example .envFill in .env:
# Generate: openssl rand -hex 16
ENCRYPTION_KEY=your_32_char_key_here
# From https://developer.whoop.com
WHOOP_CLIENT_ID=your_client_id
WHOOP_CLIENT_SECRET=your_client_secret
# Database password
POSTGRES_PASSWORD=your_secure_password- Add
http://localhost:8081/callbackto your WHOOP App's Redirect URIs in the Developer Dashboard. - Generate tokens:
export WHOOP_CLIENT_ID=your_id export WHOOP_CLIENT_SECRET=your_secret go run cmd/auth/main.go
- Complete the authorization in your browser. Your WHOOP User ID will be auto-detected and saved to
.env. - If deploying remotely, copy
.whoop_token.jsonto the server.
docker compose up -d --build- Ensure your server is accessible via HTTPS.
- Configure your WHOOP Webhook URL:
https://your-domain.com/webhook. - Set
WHOOP_WEBHOOK_SECRETin.env. - Start:
docker compose -f docker-compose.prod.yml up -d --build
The dashboard has five main sections, accessible from the sidebar (desktop) or bottom nav (mobile):
| Tab | What it shows |
|---|---|
| Overview | Today's strain, recovery score, sleep performance, 7-day recovery strip, 30-day strain/recovery trend chart, and recent workouts |
| Recovery | Recovery gauge, HRV, resting heart rate, SpO2, skin temperature trends, recovery distribution, and 14-day history |
| Sleep | Sleep performance, efficiency, duration, stage breakdowns (light/REM/deep/awake), sleep debt, respiratory rate, and consistency |
| Strain | Daily strain score, calorie burn (converted from kJ), HR zones, sport breakdown, peak strain days, and workout statistics |
| Workouts | Full workout feed with sport type, duration, strain, calories, average/max HR, distance, and HR zone breakdowns |
- Automatic (Polling Mode): Data syncs on configurable intervals — defaults: cycles every 4h, workouts every 30m, sleep every 1h, profile daily.
- Manual: Click the Sync button on the Overview page. This triggers an ad-hoc API call and refreshes all dashboard routes.
Adjust these in .env using Go duration format:
POLL_INTERVAL_CYCLE=4h # How often to fetch physiological cycles
POLL_INTERVAL_WORKOUT=30m # How often to fetch workouts
POLL_INTERVAL_SLEEP=1h # How often to fetch sleep data
POLL_INTERVAL_SLEEP_OFFPEAK=4h # Sleep polling outside 6 AM–12 PM
POLL_INTERVAL_PROFILE=24h # User profile data (rarely changes)Default ports are 8085 (backend) and 3032 (frontend). Change them in .env:
BACKEND_PORT=9090
FRONTEND_PORT=3000Then update NEXT_PUBLIC_API_URL in docker-compose.yml if you changed the backend port.
| Metric | Description | Source |
|---|---|---|
| Strain | 0–21 scale of daily cardiovascular load | WHOOP cycles |
| Recovery | 0–100% readiness score (green ≥66%, yellow ≥34%, red <34%) | WHOOP recovery |
| HRV (RMSSD) | Heart rate variability in ms — higher is better | WHOOP recovery |
| Sleep Performance | Percentage of sleep need achieved | WHOOP sleep |
| Sleep Efficiency | Time asleep / time in bed (%) | WHOOP sleep |
| Calories | Total energy expenditure, converted from kilojoules (kJ × 0.239) | WHOOP cycles |
# Unit tests (no Docker required)
go test ./internal/crypto/... ./internal/middleware/... ./internal/config/... -v
# Timezone parser tests
go test ./internal/storage/ -run TestParseTimezoneOffset -v
# Integration tests (requires Docker for testcontainers)
go test ./internal/storage/ -v
# All tests
go test ./...- AES-256-GCM Encryption — OAuth tokens encrypted at rest with a user-provided key. Database dumps cannot compromise API access.
- JWT HS256 Enforcement — Auth middleware rejects tokens using any algorithm other than HS256, preventing algorithm confusion attacks.
- Per-IP Rate Limiting — 20 req/s with burst of 50. Stale entries cleaned up automatically to prevent memory leaks.
- Non-Root Containers — Both backend and frontend processes run as unprivileged users.
- Token File Permissions —
.whoop_token.jsoncreated with0600(owner read/write only). - No Hardcoded Secrets — All secrets fail fast at startup if missing, with clear error messages.
Set ENCRYPTION_KEY (exactly 32 hex characters) in .env. Generate one:
openssl rand -hex 16Register at developer.whoop.com and set WHOOP_CLIENT_ID and WHOOP_CLIENT_SECRET in .env.
The frontend can't reach the backend API:
- Is the backend container running?
docker compose ps - Is
NEXT_PUBLIC_API_URLpointing to the correct backend address/port? - Check backend logs:
docker compose logs backend
Double-check WHOOP_CLIENT_ID and WHOOP_CLIENT_SECRET in your WHOOP Developer Dashboard. Ensure http://localhost:8081/callback is added as a Redirect URI in your WHOOP app settings.
WHOOP refresh tokens are single-use — each refresh returns a new token and invalidates the old one. This error usually means the refresh token in .whoop_token.json has already been consumed.
Fix: Regenerate the token locally and copy to your server:
# On your local machine (needs Go + browser)
go run cmd/auth/main.go
# Copy to NAS/server
scp .whoop_token.json user@your-server:/path/to/whoop-stats/Then on the server:
docker compose exec timescaledb psql -U whoop_user -d whoop_stats -c "DELETE FROM users;"
docker compose restart backendNote: As of v0.0.1, the backend automatically writes refreshed tokens back to
.whoop_token.jsonafter each successful refresh. This means DB wipes no longer invalidate your token — you only need to re-run the OAuth flow if the token has been consumed without being persisted.
- First deploy? The initial sync takes 1–2 minutes. Watch:
docker compose logs -f backend - Check for errors:
docker compose logs backend | grep ERROR - Try manual sync: Click the "Sync" button on the Overview page.
- Token issue? Look for
refreshing tokenerrors in logs — see "Token refresh fails" above.
- Ensure TimescaleDB is healthy:
docker compose ps - If you changed
POSTGRES_PASSWORDafter first run, wipe the volume:docker compose down sudo rm -rf ./data/timescaledb docker compose up -d
Another service is using the same port. Change BACKEND_PORT or FRONTEND_PORT in .env:
BACKEND_PORT=9090
FRONTEND_PORT=4000Continuous aggregates refresh hourly with a 3-day lookback. For immediate results:
- Click "Sync" on the Overview page
- Verify poll intervals in
.envare reasonable
If code changes don't seem to take effect, Docker may be using cached layers:
docker compose build --no-cache backend
docker compose up -d backendBoth images build on ARM64 natively. The TimescaleDB image (timescale/timescaledb:latest-pg15) supports ARM64.
Watchtower works seamlessly — it only replaces container images, not volumes. Your .whoop_token.json (bind mount) and database (./data/timescaledb) persist across updates. Add to your docker-compose.yml:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --interval 86400 whoop-stats-backend whoop-stats-frontend| Layer | Technology | Role |
|---|---|---|
| Database | PostgreSQL 15 + TimescaleDB | Time-series storage with hypertables and continuous aggregates |
| Backend | Go 1.25+, go-chi, sqlc | REST API, dual-mode ingestion, type-safe DB queries |
| Frontend | Next.js 16, React 19, Tailwind CSS v4 | Server-rendered dashboard with glassmorphism UI |
| Charts | Recharts, Framer Motion | Interactive data visualization and animations |
| Auth | JWT (HS256), AES-256-GCM | API authentication and token encryption |
| DevOps | Docker, Docker Compose | Container orchestration with SSD-optimized logging |
whoop-stats/
├── cmd/
│ ├── auth/ # One-time OAuth token generator
│ └── server/ # Main backend entrypoint (poll + webhook modes)
├── internal/
│ ├── api/ # HTTP handlers and router setup
│ ├── auth/ # OAuth2 token management
│ ├── config/ # Environment configuration (viper)
│ ├── crypto/ # AES-256-GCM encryption
│ ├── db/ # Generated sqlc code (DO NOT EDIT)
│ ├── middleware/ # Auth, rate limiting, logging
│ ├── poller/ # WHOOP API polling engine
│ ├── storage/ # Database abstraction layer
│ └── webhook/ # Webhook handler and background worker
├── migrations/ # SQL schema migrations
├── queries/ # sqlc query definitions
├── web/ # Next.js frontend
│ └── src/
│ ├── app/ # Pages (overview, recovery, sleep, strain, workouts)
│ ├── components/# UI components
│ └── lib/ # API client, utilities, formatting helpers
├── docker-compose.yml # Development / homelab deployment
├── docker-compose.prod.yml # Production deployment (named volumes)
├── setup.sh # Interactive setup wizard
└── .env.example # Configuration template
MIT