From 2a8be9fc4a6228be43c15b902a5e0e40e2a8207c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 12:43:09 +0200 Subject: [PATCH] docs(integrations): pin exact WHOOP scopes + webhook URL, add Google Health setup --- docs/integrations/google-health.md | 79 ++++++++++++++++++++++++++++ docs/integrations/whoop.md | 32 ++++++++--- docs/self-hosting/getting-started.md | 1 + 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 docs/integrations/google-health.md diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md new file mode 100644 index 000000000..98d96db88 --- /dev/null +++ b/docs/integrations/google-health.md @@ -0,0 +1,79 @@ +# Google Health integration (Fitbit & Pixel) + +> **Beta.** Field mapping is still being verified against live Google Health +> accounts, so some values may be missing or change in a future update. + +HealthLog reads Fitbit and Pixel Watch data through the **Google Health API**. +The OAuth app is therefore a **Google Cloud** OAuth client — not a Fitbit +developer-console app. Like WHOOP, credentials are brought per user: each user +registers their own Google Cloud OAuth client and pastes the client id/secret +into Settings. The internal integration key is `fitbit`. + +## 1. Create a Google Cloud OAuth client + +1. In create (or pick) a project and, under + **APIs & Services**, enable the **Google Health API**. +2. Configure the OAuth consent screen for the project. +3. Under **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of + type **Web application**. +4. **Authorized redirect URI:** + `https://your-instance.example.com/api/fitbit/callback` +5. Save. Google issues a **Client ID** and **Client Secret** — keep the secret + out of any chat log. + +## 2. Scopes + +HealthLog requests exactly these read-only scopes: + +``` +https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly +https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly +https://www.googleapis.com/auth/googlehealth.sleep.readonly +https://www.googleapis.com/auth/googlehealth.profile.readonly +``` + +Location and intraday scopes are deliberately omitted to keep the +Restricted-scope review surface minimal. The authorize request sends +`access_type=offline` + `prompt=consent` — Google's requirement to issue a +refresh token (the equivalent of WHOOP's `offline` scope). + +## 3. Wire the credentials in Settings → Integrations + +Credentials are stored per user, not in `.env`. Sign in as the target user, open +**Settings → Integrations → Google Health**, and paste the client ID and secret. +They are encrypted AES-256-GCM at rest. + +## 4. Connect + +Click **Connect with Google Health**. HealthLog redirects to Google's OAuth +consent screen, exchanges the returned code for an access + refresh token, stores +them encrypted, and runs an initial sync. There is **no webhook** — Google Health +syncs on the safety-net cron pull. + +## Redirect URI is validated strictly + +The resolved `redirect_uri` is asserted at connect time to be absolute, `https` +(or `http` on localhost), to target exactly `/api/fitbit/callback`, and — when +`FITBIT_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to share its +origin. A misconfigured value fails closed rather than redirecting elsewhere. + +## Optional instance env var + +```env +# Only when the callback origin differs from NEXT_PUBLIC_APP_URL: +FITBIT_REDIRECT_URI="https://your-instance.example.com/api/fitbit/callback" +``` + +The Google Cloud client id/secret are per-user (Settings), not env vars. + +## Source overlap + +If Google Health and another source (Withings, WHOOP, Apple Health) both supply +the same vital — resting heart rate, blood oxygen, body temperature or +respiratory rate — you may see both values until a future update settles on a +single preferred source. + +## Disconnect + +The Settings **Disconnect** button revokes the stored tokens and removes the +connection. Previously synced measurements stay in the database. diff --git a/docs/integrations/whoop.md b/docs/integrations/whoop.md index 10a87f629..d9f9ce759 100644 --- a/docs/integrations/whoop.md +++ b/docs/integrations/whoop.md @@ -21,8 +21,16 @@ grant scoped to credentials they control. 2. **Redirect URI:** `https://your-instance.example.com/api/whoop/callback` (matches `WHOOP_REDIRECT_URI`, below). Add one entry per environment you connect from. -3. Request the scopes for recovery, cycle/strain, sleep, and the - workout/heart-rate reads the sync uses. +3. Request these scopes (the exact set HealthLog asks for): + + ``` + offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement + ``` + + `offline` is **mandatory** — without it WHOOP issues no refresh token + and the connection dies when the first access token expires (~1 hour). + The remaining `read:*` scopes are read-only; request all of them so the + user grants once and every sync resource is covered. 4. Save the app. WHOOP issues a **Client ID** and **Client Secret** — keep the secret out of any chat log; it grants read access to the linked account's full WHOOP history. @@ -59,10 +67,22 @@ WHOOP_WEBHOOK_SECRET="$(openssl rand -hex 32)" - `WHOOP_REDIRECT_URI` — the OAuth callback URL handed to WHOOP. Must match a redirect URI registered in the WHOOP developer app. -- `WHOOP_WEBHOOK_SECRET` — the secret HealthLog verifies the WHOOP - webhook HMAC signature against. WHOOP signs each webhook body; the - handler validates the signature before processing. Generate a fresh - random value and restart the `app` container after setting it. +- `WHOOP_WEBHOOK_SECRET` — used twice: as the **trailing path segment** + of the webhook URL you register in the WHOOP console, and as the key + HealthLog verifies the WHOOP HMAC body signature against. Set the + webhook URL in the WHOOP console to: + + ``` + https://your-instance.example.com/api/whoop/webhook/ + ``` + + WHOOP signs each body (`X-WHOOP-Signature` + `X-WHOOP-Signature-Timestamp`, + HMAC-SHA256); the handler rejects deliveries older than five minutes and + validates the signature before processing. The body signing key on WHOOP's + side must equal `WHOOP_WEBHOOK_SECRET`. Generate a fresh random value and + restart the `app` container after setting it. Without a webhook, WHOOP + still syncs on the safety-net cron pull — only the near-real-time push is + lost. ## Disconnect diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index e3d02362e..96f1db1aa 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -152,6 +152,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build | Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | | Withings device sync | `docs/integrations/withings.md` | | WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` | +| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` | | Apple Health `export.zip` import | `docs/integrations/apple-health.md` | | AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |