The shipped Fitbit integration spoke the Google Health API, whose Restricted scopes need brand verification plus an annual CASA assessment before leaving testing mode — a wall most self-hosters cannot clear. Retarget the transport to the classic api.fitbit.com Web API: a self-hoster registers an app at dev.fitbit.com in minutes, no CASA. Forks only the transport; the upsert/dedup/rollup tail, source-priority, backfill, poll worker, and status ledger are reused unchanged. - OAuth: www.fitbit.com/oauth2/authorize + api.fitbit.com/oauth2/token, PKCE (S256), classic self-serve scopes. - Refresh tokens rotate (one-time use): persist the new token every refresh, with a defensive keep-existing guard on a malformed reply. - Per-endpoint date-range fetchers chunked to the API range caps; the backfill horizon and poll concurrency are tuned for the 150 req/h-per-user budget. - Verified classic-shape extractors for weight, body fat, SpO2, HRV (dailyRmssd), resting HR, respiratory rate, steps, distance, active calories, floors, VO2 max (range midpoint), per-segment sleep stages, and workouts. - Skips intraday HR (needs a per-app grant; daily resting HR covers it) and skin temperature (a baseline delta with no honest absolute slot).
8.5 KiB
Fitbit Web API field → Measurement mapping
Single source of truth for the classic Fitbit Web API (api.fitbit.com) →
HealthLog Measurement mapping. Keep this in sync with FITBIT_FIELD_MAP and
the per-type mappers in client.ts. Every Fitbit row ingests server-side with
source = FITBIT; the externalId is a stable per-reading key so a re-fetch of
the same window overwrites in place rather than minting a duplicate.
Transport: v1.20.0 retargeted Fitbit from the Google Health API (Restricted
scopes behind brand-verification + CASA — a self-hoster adoption wall) onto the
classic Web API. A self-hoster registers an app at dev.fitbit.com in minutes
(no CASA). Auth: Authorization Code + PKCE (S256);
www.fitbit.com/oauth2/authorize + api.fitbit.com/oauth2/token. Refresh tokens
rotate (one-time use) — the new token must be persisted every refresh.
Deprecation note: the classic Web API is announced for deprecation in September 2026, with migration directed back at the Google Health API. Until a self-serve Google path exists, the classic API is the only viable transport for self-hosters; the settings card keeps its experimental badge.
All data reads send Accept-Language: en_GB so weight + distance arrive in
metric (kg / km); the US default is imperial.
Health-metrics endpoints
Scope: weight, oxygen_saturation, heartrate, respiratory_rate. Verified
against the dev.fitbit.com reference (response shapes quoted there).
| Endpoint | MeasurementType | Unit | Source field | Grain | Note |
|---|---|---|---|---|---|
/1/user/-/body/log/weight/date/{s}/{e}.json |
WEIGHT |
kg | weight[].weight |
log | externalId anchors on logId. Picker ranks Withings above. |
/1/user/-/body/log/fat/date/{s}/{e}.json |
BODY_FAT |
% | fat[].fat |
log | externalId anchors on logId. |
/1/user/-/spo2/date/{s}/{e}.json |
OXYGEN_SATURATION |
% | [].value.avg (bare array) |
daily | day-keyed externalId. |
/1/user/-/hrv/date/{s}/{e}.json |
HEART_RATE_VARIABILITY |
ms | hrv[].value.dailyRmssd |
daily | RMSSD estimator → canonical HRV slot (not HRV_RMSSD). |
/1/user/-/activities/heart/date/{s}/{e}.json |
RESTING_HEART_RATE |
bpm | activities-heart[].value.restingHeartRate |
daily | rows with no resting HR for the day are skipped. |
/1/user/-/br/date/{s}/{e}.json |
RESPIRATORY_RATE |
breaths/min | br[].value.breathingRate |
daily |
Each daily-summary value passes a finite + strictly-positive guard (a 0/NaN is a
garbage/empty reading and is dropped). Each endpoint caps its own date range
(30–31 days for these), so the sync chunks [start, end] into ≤30-day windows.
Activity + VO2 max endpoints
Scope: activity, cardio_fitness. Per-day summaries; the externalId carries
the stats: daily-total prefix — stats:<fieldTag>:<YYYY-MM-DD> — so a
re-fetched day overwrites in place (Apple-Health stats:<HK>:<YYYY-MM-DD>
contract). The running totals preserve a 0 (a rest day is real data); VO2 max
stays strictly positive.
| Endpoint | MeasurementType | Unit | Source field | Note |
|---|---|---|---|---|
/1/user/-/activities/steps/date/{s}/{e}.json |
ACTIVITY_STEPS |
steps | activities-steps[].value (string) |
daily total; 0 valid |
/1/user/-/activities/distance/date/{s}/{e}.json |
WALKING_RUNNING_DISTANCE |
m | activities-distance[].value |
km → m (×1000) under the metric locale |
/1/user/-/activities/activityCalories/date/{s}/{e}.json |
ACTIVE_ENERGY_BURNED |
kcal | activities-activityCalories[].value |
ACTIVE portion only (excludes BMR) |
/1/user/-/activities/floors/date/{s}/{e}.json |
FLIGHTS_CLIMBED |
flights | activities-floors[].value |
daily total; 0 valid |
/1/user/-/cardioscore/date/{s}/{e}.json |
VO2_MAX |
mL/(kg·min) | cardioScore[].value.vo2Max |
string — a single number or a "lo-hi" range → midpoint |
Activity time-series values arrive as numeric strings ("2504"). VO2 max
arrives as a string that may be a single value ("45") or a range ("44-48",
when no GPS run data exists) — the range resolves to its midpoint.
Sleep endpoint
Scope: sleep. /1.2/user/-/sleep/date/{s}/{e}.json →
sleep[].levels.data[] ({ dateTime, level, seconds }). One SLEEP_DURATION
row per segment, measuredAt = segment START + seconds = segment END, value =
seconds → minutes. The 1.2 log carries a real per-segment series, so the rows lay
each block at its true clock time (a MEASURED timeline — rows are NOT flagged
reconstructed). externalId = <logId>:sleep_<stage>:<i> (indexed so several
segments of one stage stay distinct), so a re-scored night overwrites in place.
The segment dateTime is local wall-clock ISO without an offset (the night
belongs to the user's local clock) — parsed as local time.
Stage map (stages logs + classic logs): light → CORE (Fitbit "light" ↔ Apple
"core" shallow-NREM band), deep → DEEP, rem → REM, wake/awake/restless → AWAKE, classic asleep → ASLEEP, in_bed → IN_BED. Unknown labels are skipped,
not mis-bucketed.
Workouts (activity log list)
Scope: activity. /1/user/-/activities/list.json?afterDate=...&sort=asc&offset&limit
→ activities[]. Each → one Workout row (NOT a Measurement), keyed
(userId, source: "FITBIT", externalId=logId). Fields: sportType
(activityName → canonical WorkoutSportType, other fallback),
startedAt/endedAt (startTime + duration ms), durationSec,
totalEnergyKcal (calories), totalDistanceM (distance km → m),
avgHeartRate (averageHeartRate). The classic list endpoint does NOT surface
min/max HR, so those stay null. A Fitbit run and the same run via Apple Health /
WHOOP stay distinct rows; the read-time pickCanonicalWorkoutRows picker
collapses the cross-source twin (FITBIT ranks just below WHOOP in the default
ladder).
Deliberately skipped
- Intraday heart rate →
PULSE. The classic API gates intraday HR + step series behind a Personal-app type or a per-app intraday access request at dev.fitbit.com that most self-hosters do not have. Daily resting HR is always available and is synced; intraday is left out so a missing grant never parks the connection. (A per-endpoint 403 also soft-skips, as a safety net.) - Skin temperature.
/1/user/-/temp/skin/...returnsvalue.nightlyRelative— a delta from the user's personal baseline, not an absolute temperature. Mapping a baseline delta into the absoluteWRIST_TEMPERATURE/BODY_TEMPERATUREslot would write misleading values, so the metric is not mapped (andtemperatureis not requested). Revisit only if a relative-temperature slot is added. - Blood glucose / blood pressure. No classic self-serve endpoint maps cleanly to the HealthLog slots at launch.
Idempotency
(userId, type, source: "FITBIT", externalId) unique. Log-anchored metric rows:
externalId = <logId>:<fieldTag>. Daily-summary rows: externalId = <YYYY-MM-DD>:<fieldTag>. Daily cumulative activity rows: externalId = stats:<fieldTag>:<YYYY-MM-DD> (Apple-Health overwrite shape). Sleep:
<logId>:sleep_<stage>:<i>. Workouts: (userId, source: "FITBIT", externalId)
on the Workout table. A re-fetch of the same window (daily summaries settle
after the fact, so the incremental overlap is 24 h) overwrites in place.