Merge pull request #399 from MBombeck/fix/fitbit-setup-docs

fix(fitbit): correct the setup guide to the classic Fitbit Web API (#396)
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-04 14:55:30 +02:00
commit 73820016c1
8 files changed
+186 -88

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
## [1.26.1] — 2026-07-02 — Fitbit setup guide fix
### Fixed
- The Fitbit (Fitbit & Pixel) setup guide described the wrong OAuth app. HealthLog connects through the classic Fitbit Web API, so you register a **Fitbit developer app at dev.fitbit.com** and paste that Client ID/Secret — but the guide walked you through creating a **Google Cloud** OAuth client instead, which the Fitbit sign-in rejects with `unauthorized_client — Invalid client_id`. The guide now describes the correct dev.fitbit.com setup (app type, HTTPS callback, scopes), and flags that Google is retiring the classic Fitbit Web API in September 2026.
## [1.26.0] — 2026-07-02 — Light theme and a consistent interface
### Changed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.26.0
version: 1.26.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+99
View File
@@ -0,0 +1,99 @@
# Fitbit integration (Fitbit & Pixel)
> **Experimental.** Field mapping is still being verified against live accounts,
> so some values may be missing or change in a future update.
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
operator on their behalf) registers their own Fitbit app and pastes the client
id/secret into Settings. The internal integration key is `fitbit`.
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
> `.apps.googleusercontent.com`. A Google Cloud client fails at the Fitbit
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
> and Google Cloud are separate client registries.
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
> Web API in **September 2026** and moving to the Google Health API behind Google
> sign-in. That replacement currently requires Google Restricted-scope brand
> verification plus an annual third-party CASA security assessment, which does
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
> sunset is still being worked out. Until then the setup below is the supported
> way to connect Fitbit.
## 1. Register a Fitbit developer app
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
2. **OAuth 2.0 Application Type:**
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
and gets intraday (per-minute) heart-rate/steps automatically. This is the
right choice for a single-person self-host.
- **Server** (or **Client**) — needed when the instance connects **other
people's** accounts. Intraday/heart-rate access is then granted case by
case via Fitbit's Intraday Data Access request form.
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
the redirect-URI note below). One entry per environment you connect from.
4. **Scopes:** tick the ones HealthLog requests (next section).
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
— keep the secret out of any chat log.
## 2. Scopes
HealthLog requests exactly these read scopes (space-separated on the wire):
```
activity cardio_fitness heartrate oxygen_saturation profile respiratory_rate sleep weight
```
The `temperature` scope is intentionally omitted — the classic skin-temperature
reading is a baseline delta rather than an absolute value, so it has no honest
canonical slot yet. The authorization uses the Authorization Code Grant **with
PKCE (S256)**, which Fitbit recommends; HealthLog mints the `code_verifier`
server-side and never exposes it to the browser.
## 3. Wire the credentials in Settings → Integrations
Credentials are stored per user, not in `.env`. Sign in as the target user, open
**Settings → Integrations → Fitbit**, and paste the Client ID and Client Secret.
They are encrypted AES-256-GCM at rest.
## 4. Connect
Click **Connect with Fitbit**. HealthLog redirects to the Fitbit consent screen
(`www.fitbit.com/oauth2/authorize`), exchanges the returned code for an access +
refresh token at `api.fitbit.com/oauth2/token`, stores them encrypted, and runs
an initial sync. There is **no webhook** — Fitbit syncs on the safety-net cron
pull. Classic Fitbit refresh tokens rotate (one-time use); HealthLog replaces the
stored token on every refresh.
## 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 Fitbit client id/secret are per-user (Settings), not env vars.
## Source overlap
If Fitbit 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.
+13 -76
View File
@@ -1,79 +1,16 @@
# Google Health integration (Fitbit & Pixel)
# Google Health / Fitbit integration
> **Beta.** Field mapping is still being verified against live Google Health
> accounts, so some values may be missing or change in a future update.
> **This page moved.** HealthLog connects Fitbit and Pixel Watch data through the
> classic **Fitbit Web API** (an app registered at
> [dev.fitbit.com](https://dev.fitbit.com/apps/new)), **not** through a Google
> Cloud OAuth client. If you were told to create a Google Cloud client and hit
> `unauthorized_client — Invalid client_id`, that is why.
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`.
See **[Fitbit integration (Fitbit & Pixel)](./fitbit.md)** for the correct setup.
## 1. Create a Google Cloud OAuth client
1. In <https://console.cloud.google.com> 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.
Background: Google is retiring the classic Fitbit Web API in September 2026 in
favour of the Google Health API behind Google sign-in. That replacement requires
Restricted-scope brand verification plus an annual CASA security assessment,
which does not currently fit a self-hosted bring-your-own-credentials model, so
until the sunset the Fitbit developer-app path documented above is the supported
way to connect.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.26.0",
"version": "1.26.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -1,6 +1,6 @@
"use client";
// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a
// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a
// BYO-key credentials form first, then an OAuth connect, then the
// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
// reads off the consolidated /api/integrations/status envelope (the per-card
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { FITBIT_OAUTH_SCOPE } from "../client";
/**
* Guard against the class of bug reported in #396: the setup docs drifting away
* from what the code actually does. The Fitbit integration uses the CLASSIC
* Fitbit Web API (an app from dev.fitbit.com), not a Google Cloud OAuth client —
* a doc that says otherwise sends users to the wrong registry and fails with
* `unauthorized_client — Invalid client_id`.
*/
const readDoc = (name: string): string =>
readFileSync(join(process.cwd(), "docs", "integrations", name), "utf8");
describe("Fitbit setup docs stay in sync with the code", () => {
const fitbitDoc = readDoc("fitbit.md");
it("documents every OAuth scope the client actually requests", () => {
for (const scope of FITBIT_OAUTH_SCOPE.split(" ")) {
expect(fitbitDoc).toContain(scope);
}
});
it("points at the classic Fitbit endpoints and dev.fitbit.com", () => {
expect(fitbitDoc).toContain("dev.fitbit.com");
expect(fitbitDoc).toContain("www.fitbit.com/oauth2/authorize");
expect(fitbitDoc).toContain("api.fitbit.com/oauth2/token");
expect(fitbitDoc).toContain("/api/fitbit/callback");
});
it("never instructs the user to create a Google Cloud OAuth client (the #396 mistake)", () => {
// The "do this" markers of the wrong walkthrough: the Google Cloud console
// and the Google Health scope URLs. Mentioning `apps.googleusercontent.com`
// as a "don't paste this" warning is allowed and intentional.
expect(fitbitDoc).not.toContain("console.cloud.google");
expect(fitbitDoc).not.toContain("googleapis.com/auth/googlehealth");
});
it("keeps the old google-health page as a pointer to the Fitbit setup", () => {
const legacy = readDoc("google-health.md");
expect(legacy).toContain("fitbit.md");
// The legacy page must not re-introduce the wrong Google Cloud walkthrough.
expect(legacy).not.toContain("console.cloud.google");
expect(legacy).not.toContain("googleapis.com/auth/googlehealth");
});
});
@@ -204,9 +204,16 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
const prisma = getPrismaClient();
const user = await seedSession("sleep-reconcile-user");
// Contiguous overnight: 22:30 → 06:15 local (Berlin, UTC+2 in June),
// stage ends straddling UTC midnight. WHOOP + Apple Health both report
// it; WHOOP wins the default ladder.
// Contiguous overnight, stage rows straddling a UTC midnight
// (≈23:30 → 06:15 Berlin). WHOOP + Apple Health both report it;
// WHOOP wins the default ladder. Anchor the night ~6 days back so it
// stays inside the route's trailing 30-day window as the calendar
// advances — fixed calendar dates seeded here expire out of the
// window and the test starts failing a month later.
const midnight = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000);
midnight.setUTCHours(0, 0, 0, 0);
const at = (offsetMin: number) =>
new Date(midnight.getTime() + offsetMin * 60_000).toISOString();
const seed = async (
iso: string,
stage: "CORE" | "DEEP" | "REM",
@@ -227,13 +234,13 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
});
};
// WHOOP — the canonical source. CORE 60 + DEEP 90 + REM 120 + CORE 195.
await seed("2026-06-03T21:30:00.000Z", "CORE", 60, "WHOOP");
await seed("2026-06-03T23:00:00.000Z", "DEEP", 90, "WHOOP");
await seed("2026-06-04T01:00:00.000Z", "REM", 120, "WHOOP");
await seed("2026-06-04T04:15:00.000Z", "CORE", 195, "WHOOP");
await seed(at(-150), "CORE", 60, "WHOOP"); // 21:30 UTC, day before
await seed(at(-60), "DEEP", 90, "WHOOP"); // 23:00 UTC, day before
await seed(at(60), "REM", 120, "WHOOP"); // 01:00 UTC
await seed(at(255), "CORE", 195, "WHOOP"); // 04:15 UTC
// Apple Health parallel rows for the SAME night — must be dropped.
await seed("2026-06-03T21:35:00.000Z", "CORE", 55, "APPLE_HEALTH");
await seed("2026-06-04T01:05:00.000Z", "REM", 110, "APPLE_HEALTH");
await seed(at(-145), "CORE", 55, "APPLE_HEALTH"); // 21:35 UTC, day before
await seed(at(65), "REM", 110, "APPLE_HEALTH"); // 01:05 UTC
const { GET } = await import("@/app/api/analytics/route");
const response = await (