Three end-to-end integration walkthroughs closing the docs gaps the
v1.4.34.3 docs-depth audit flagged as critical user-facing surfaces.
Apple Health (docs/integrations/apple-health.md, 200 lines):
- Generating the iOS `export.zip` from the Health app.
- 1.5 GB upload cap, Zip64-aware streaming parser, content-hash
idempotency.
- Per-record / per-workout / per-correlation / per-clinical-record
coverage matrix grounded in the parser's source.
- Cumulative HK-type daily aggregation contract
(`stats:<HKType>:<YYYY-MM-DD>` external IDs).
- Live status-polling endpoint and the admin-import variant.
- Source-priority interaction with the existing Withings ladder.
Withings (docs/integrations/withings.md, 195 lines):
- Developer-portal app registration with the right OAuth scopes
(`user.metrics user.activity`) and redirect URI.
- Env-var layout (`WITHINGS_CLIENT_ID`, `WITHINGS_CLIENT_SECRET`,
`WITHINGS_WEBHOOK_SECRET`).
- Why the webhook URL carries the secret as a path segment
(v1.4.25 W17a — reverse proxies log `query_string` by default;
path segments aren't logged, so the secret stays off the access
log).
- Sync coverage (weight, body composition, BP, sleep, activity,
SpO₂), reauth handling, troubleshooting.
AI providers (docs/integrations/ai-providers.md, 244 lines):
- All four `User.aiProvider` values (OPENAI, ANTHROPIC, LOCAL,
CHATGPT_OAUTH).
- Default models, BYOK vs admin-shared, the provider fallback chain.
- Local-endpoint setup with Ollama, LM Studio, and vLLM examples;
SSRF-guard rationale; ChatGPT device-code OAuth pointer.
- Privacy stance — local endpoints keep measurement context inside
your network; hosted endpoints send the snapshot to the provider.
8.1 KiB
Withings integration
HealthLog connects to Withings via OAuth2 plus a push webhook for near-real-time sync. Once linked, every Body+ scale weighing, BPM blood-pressure reading, ScanWatch sleep stage, and activity sample lands in HealthLog within seconds — no polling, no third-party relay.
What gets synced
The Withings client (src/lib/withings/client.ts) maps the following
metric types into Measurement rows tagged source = WITHINGS:
| Withings meastype | HealthLog MeasurementType |
|---|---|
| 1 | WEIGHT |
| 5 | FAT_FREE_MASS |
| 9 | BLOOD_PRESSURE_DIA |
| 10 | BLOOD_PRESSURE_SYS |
| 11 | PULSE |
| 54, 35 | OXYGEN_SATURATION (ScanWatch / pulse-ox products) |
| Plus the body-composition family (fat ratio, muscle mass, bone mass, hydration) and sleep / heart-rate variability via the heart-list endpoint |
Activity data (steps, active energy, distance, floors climbed) lands
via the user.activity OAuth scope shipped in v1.4.25. Sleep stages
arrive via the sleep series endpoint.
1. Register a Withings developer app
- Sign in at https://developer.withings.com/ and create a new public-API application. The "Public" tier is free and supports the OAuth2 flow HealthLog uses; you do not need the partner tier.
- Callback URL:
https://your-instance.example.com/api/withings/callback. Withings allows multiple callbacks per app — add one entry per environment (production, staging, local development against a tunnel). - Scopes: request
user.metricsANDuser.activity. Both are needed —user.metricscovers weight / BP / heart-list / sleep / SpO₂ / body comp / temperature / VO₂ max;user.activityis the workout-equivalent stream for steps / energy / distance / floors. Seesrc/lib/withings/client.ts:42(WITHINGS_OAUTH_SCOPE). - Save the application. Withings will issue a Client ID and a Client Secret — keep the secret out of any chat log; it grants read access to every linked account's full health history.
2. Wire the credentials into HealthLog
Paste the client ID and secret into .env:
WITHINGS_CLIENT_ID="your-client-id"
WITHINGS_CLIENT_SECRET="your-client-secret"
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
WITHINGS_WEBHOOK_SECRET is the path-segment token the webhook
handler validates against. Generate a fresh random value — anything
that fits in a URL path segment works, but a 64-character hex string
keeps it indistinguishable from random. Restart the app container
after saving.
Per-user override is also possible: the Settings UI accepts a client-ID/secret pair scoped to one user, useful if you want a shared instance where every user brings their own Withings app credentials. Server-level env vars cover the default case.
3. Set the webhook URL
When HealthLog calls Withings Notify.subscribe() for a newly-linked
account, it hands Withings the callback URL constructed by
getWithingsWebhookCallbackUrl() (src/lib/withings/sync.ts:38-48).
The shape is:
https://your-instance.example.com/api/withings/webhook/<WITHINGS_WEBHOOK_SECRET>
Withings has no facility for adding HTTP headers or signing webhook
bodies, so the callback URL itself is the only authenticity surface
a subscriber controls. The v1.4.25 W17a migration moved the secret
from a query parameter (?secret=…, captured by every reverse-proxy
access log) to a path segment, which keeps it out of the
query_string column most proxies log by default
(src/app/api/withings/webhook/[token]/route.ts:13-23).
You do not need to register the webhook URL anywhere in the developer portal — HealthLog subscribes per-user during the OAuth callback. The portal only owns the OAuth callback URL.
4. Link from the Settings page
With env vars in place and the app restarted, sign in as the target
user and open /settings/integrations/withings. Click Connect
Withings. The flow:
- The Connect button redirects to Withings'
oauth2/authorize. - The user signs in on Withings and grants the requested scopes.
- Withings redirects back to
/api/withings/callbackwith an auth code. - HealthLog exchanges the code for an access + refresh token, stores them AES-256-GCM-encrypted at rest, subscribes the webhook URL, and triggers an initial sync of the past 30 days.
- The settings page now shows Connected with the timestamp of the last successful sync.
Subsequent syncs run automatically on every webhook delivery plus a safety-net cron pull every few hours.
Source-priority interaction
HealthLog's source ladder ranks WITHINGS ≻ APPLE_HEALTH ≻ MANUAL
for point measurements (weight, BP, pulse, body fat, body
temperature, SpO₂, VO₂ max) and APPLE_HEALTH ≻ WITHINGS ≻ MANUAL
for cumulative metrics (steps, active energy, distance, flights,
sleep, HRV, resting HR). The defaults live in
src/lib/validations/source-priority.ts:205-220.
Concrete consequences if you run Withings alongside an Apple Health import:
- Weight readings from a Withings scale win for display. Apple Health rows for the same metric (received second-hand from Health Mate) stay in the DB as audit trail but drop out of the active display.
- Steps from a ScanWatch go through HealthKit's daily aggregation when Apple Health is also linked, so the Apple Health stream wins for the cumulative axis. The Withings rows stay in the DB.
Override per-user via the Sources section of /settings/thresholds.
Troubleshooting
The Connect button errors out
Withings credentials not configured—WITHINGS_CLIENT_IDorWITHINGS_CLIENT_SECRETis missing from.env. Confirm both are set in the container's runtime environment (docker compose exec app env | grep WITHINGS).Callback URI mismatch— the URL HealthLog sends to Withings must match an entry in the developer-portal app's callback list. Add the public URL exactly, including thehttps://scheme and the/api/withings/callbackpath.
The webhook stops firing
Webhook subscriptions can lapse after a token revoke or a portal-
side cleanup. Re-subscribe by opening /settings/integrations/withings
and clicking Disconnect then Connect Withings again — the
callback exchange re-runs Notify.subscribe() with a fresh callback
URL.
Test the inbound path with a HEAD request from outside the host:
curl -I https://your-instance.example.com/api/withings/webhook/<WITHINGS_WEBHOOK_SECRET>
A 200 OK means the path-segment secret matches. A 401 means the
env var on the running container does not match the secret embedded
in the URL Withings was handed at subscribe time. Re-subscribing
fixes the mismatch by updating the URL Withings holds.
Sync is stuck on "Reauthorisation required"
Withings revokes refresh tokens on password change, app un-authorise,
or after extended inactivity. The WithingsConnection row flips to
error_reauth status and the scheduled sync no-ops until the user
re-runs the OAuth flow (src/lib/withings/sync.ts:155-161).
Click Connect Withings again from the settings page; the
callback exchanges fresh tokens and the next sync runs through the
last 30 days of history to catch up.
Disconnect cleanly
The Settings page Disconnect button:
- Calls
POST /api/withings/disconnect. - Server-side: revokes the access token via Withings'
Notify.revoke, deletes theWithingsConnectionrow, and audits the action. - Existing
Measurementrows taggedsource = WITHINGSstay in the database — disconnect does not delete data. Re-linking the same Withings account merges back onto the same metric timeline; the upsert path matches on(userId, type, measuredAt, source)so previously-synced rows update in place.
Admin probe
POST /api/integrations/withings/test (admin-only) runs a
read-only round-trip against the user's stored credentials and
returns the latency in milliseconds. Use it from the admin panel
when a user reports stale data — a green test plus a stale
lastSyncedAt usually means the webhook URL Withings holds is out
of sync (re-subscribe per the steps above).