Open Wearables 0.8.0: Withings, sync history, and faster ingestion
Open Wearables 0.8.0 is available. This release adds Withings, a sync_run tracking table with two new history endpoints, a rewritten bulk upsert path that benchmarks 3.7x faster, 5 GiB Apple Health XML uploads with MinIO parity, four account takeover fixes, and multi-arch Docker images.
Two things need attention before you upgrade: a manual migration check if you track nightly, and the removal of the Garmin backfill cancel and retry endpoints. Both are covered at the bottom.
Key Takeaways
- New: Withings integration. OAuth 2.0, polling the Withings Measure and Sleep APIs by default. Body, activity, sleep, and workouts. Webhooks are optional and created per user, not per application.
- New: `sync_run` and `sync_run_data_type` tables. One row per data type per run, across provider pulls and pushes, SDK uploads, SDK logs, Apple XML imports, and Garmin backfills. Exposed via
GET /users/{id}/sync/historyandGET /sync/history/{run_key}. Historical syncs only and API-only in this release. - Bulk upsert rewritten to COPY into a staging table and merge: 3.7x faster on a 50,000-row batch locally, 6.10s down to 1.67s. XML imports moved to a dedicated Celery queue.
- Apple Health XML uploads raised to 5 GiB, with S3 and MinIO parity. The hard-coded AWS client and SNS dependency are gone, so AWS-free deployments can ingest multi-gigabyte exports.
- Optional SDK payload offload via
SDK_PAYLOAD_S3_OFFLOAD: enqueue an S3 reference instead of the body, keeping broker messages at roughly 1 KB. Off by default. - Four account takeover fixes, all backwards-compatible: hashed API keys, refresh token revocation on password change,
PATCH /developers/{id}scoped to the caller, andgarmin-client-idheader verification. - Workout provenance (
entry_source,intensity,label) from Oura, Strava, Garmin, and Apple. New series: Oura intraday MET, previously not stored at all, now parsed into timestamped samples asphysical effort. - Multi-arch images for
linux/amd64andlinux/arm64under the same tags. - Removed: Garmin backfill cancel and retry endpoints. Deprecated: Garmin backfill
GET status, and Whooprecovery_scorein the recovery summaries endpoint.
Withings
Withings is now a supported provider. All data categories are available straight away, along with the methods for integrating them: body, activity, sleep, and workouts.
The integration introduces new elements to the platform's architecture. Per the provider docs, auth is OAuth 2.0 with comma-separated scopes, and the default sync path polls the Withings Measure and Sleep APIs. Webhook push is optional: subscriptions are created per user with that user's own token rather than once per application, and an application without compliant webhooks is capped by Withings at 10 linked users. The application-wide rate limit is 120 requests per minute.
Not supported: sleep stage timeline (hypnogram), ECG, and HRV.
Sync History
Sync completion status was already tracked in Redis, with a 24-hour retention window. This release moves it into the database.
Three problems, all specific to historical syncs:
- Redis is not durable. After 24 hours the record is gone.
- No completion signal on long runs. The Redis status was written when a sync *started*, not when it finished. A historical sync covering a year of Apple data runs for a long time, and there was no way to distinguish one still in progress from one that died. The table now records when a historical sync ended.
- No resume point after a drop. When a historical sync broke partway through a large range, nothing recorded how far it had got. The per-data-type rows now show where it stopped, so it can be continued from that range instead of restarted.
A new sync_run table stores provider, scope, status, timing, item counts, and errors for each run. A companion sync_run_data_type table gives one row per data type per run, so a sync that succeeded on sleep and failed on workouts records both outcomes separately instead of an opaque blob.
Live syncs are deliberately not persisted. The data model supports them, but writing every live sync would flood the table. Historical syncs are the case where durability and resumability matter.
A stale sweep closes runs that stopped reporting. It checks Redis for liveness first, so a slow backfill is not cut off prematurely.
Tracking covers these ingestion paths:
- Provider pulls and pushes
- SDK uploads
- SDK logs
- Apple Health XML imports
- Garmin backfills
Two endpoints expose the data:
GET /users/{id}/sync/history
GET /sync/history/{run_key}
Use the first to answer "what happened for this user and when," the second to inspect a specific run by key.
This is API-only in 0.8.0. The frontend picks it up in its next iteration.
SDK Uploads Queue a Reference Instead of the Payload
Passing the full SDK upload body as a Celery task argument serialized it into the Redis broker queue. Large uploads meant multi-megabyte broker messages.
With the new SDK_PAYLOAD_S3_OFFLOAD env variable enabled, the sync endpoint enqueues only an S3 reference and the worker loads the body when the task runs. Each broker message stays at roughly 1 KB.
The payload was already being written to S3, so this removes a duplicate copy rather than adding a write.
Off by default. Nothing changes for deployments that leave it that way.
Bulk Upsert Rewritten
The DataPointSeries bulk upsert previously issued chunked INSERT ... VALUES statements carrying an enormous number of bind parameters. A batch is now COPY-ed into a temporary staging table and merged into the target with a single statement.
Locally that is a 3.7x speedup on a 50,000-row batch: 6.10s down to 1.67s.
XML imports also moved to a dedicated Celery queue, so large Apple exports no longer compete with routine provider syncs for worker capacity.
Apple Health XML: 5 GiB and MinIO Parity
The Apple Health XML upload limit went from 1 GiB to 5 GiB, with a progress dialog on the frontend.
The multipart upload flow now works identically against AWS S3 and self-hosted MinIO. Previously the XML path used a hard-coded AWS client and depended on SNS, which MinIO cannot emit. A fully local, AWS-free deployment can now ingest multi-gigabyte Apple exports through the portal.
Workout Provenance
Three providers were already sending per-workout provenance and the platform was discarding it. Workouts now carry three fields:
-
entry_source -
intensity -
label
Populated by Oura, Strava, Garmin, and Apple. These let you tell a measured workout from a manually entered one, and see the name the user gave it.
Oura Intraday MET
This is a new series, not a granularity change. Oura MET was not stored at all before this release, so nothing you already have is affected and there is no daily-to-intraday conversion.
Oura sends an intraday MET (metabolic equivalent of task) series alongside daily activity. It is now parsed into individual timestamped samples at the provider's own interval and stored as physical effort, giving a continuous picture of movement intensity across the day.
Oura is the only provider exposing MET at this granularity today, so this is currently Oura-only.
Also in this release: the invalid activity OAuth scope has been removed from the Oura defaults. Parsed Oura MET data is exposed through the workouts API.
Security
Four ways to take over an account or a deployment are now closed. All fixes are backwards-compatible.
API keys are stored hashed. The raw value is shown only once, on create or rotate. The migration hashes existing keys in place, so current integrations keep working.
Changing a password revokes every refresh token the developer holds. Refresh tokens have no expiry, so a leaked token previously worked forever.
`PATCH /developers/{id}` is restricted to the caller's own account. Before, any team member could set another developer's password.
Garmin webhooks compare the `garmin-client-id` header against the configured value, instead of just checking that it is present.
A SECURITY.md vulnerability reporting policy has been added to the repo.
Multi-Arch Docker Images
Backend and frontend images now publish for both linux/amd64 and linux/arm64 under the same tags as before. They are built via native arm64 runners.
Recommended images:
-
themomentum/open-wearables-backend -
themomentum/open-wearables-frontend
Tag scheme:
-
latest: newest stable image -
0.8.0,0.8: pinned stable release -
nightly: unstable, rebuilt nightly from main
Provider Fixes
Suunto: pause-aware stopTime and gear from SummaryExtension; missing sleep windows recovered safely; awake time fetching implemented; SpO2Max and AvgHRV extracted from sleep.
Whoop: /v2/cycle endpoint support added; HRV split into RMSSD and SDNN with recovery_score deprecated in the recovery summaries endpoint; recovery endpoint fixed and cycle_id resolved for recovery.updated; workout type mappings updated and additional sport names mapped.
Ultrahuman: real synced item counts now reported; skin temperature stored under the skin_temperature series; granular sleep stages parsed from sleep_graph.
Garmin: webhook garmin-client-id verification; backfill GC task, cancel and retry endpoints removed.
Other Changes
Token refresh race condition resolved using a distributed lock.
SDK connections: connection.created and connection.revoked events are now emitted. SDK tokens can disconnect their own connections. "Sync SDK" is renamed to "Mobile SDK."
Outgoing webhooks: connection.created and connection.revoked emitted; sleep data delivery unified between API and webhook; webhook sent after seeding sleep data; webhook event filters can now be removed.
Scores: duplicate sleep score calculation prevented.
MCP: get_menstrual_cycles tool added. FastMCP upgraded from 2 to 3.
Admin panel: metric descriptions with tooltip hints on the coverage matrix; responsive sidebar on mobile; Tanstack menu position fixed; "Device info not available" label cleared when the source has no device details.
Platform: DB connection released before the SSE stream starts; GET /users improved; handle_duplicates decorator removed from event_record_detail_service; old data migrations removed from startup; migration chain guarded against re-pointed migrations; admin seed clarified to run only on an empty developer table.
Docs: production compose setup replaced with a Docker deployment guide; provider and webhook docs refreshed; Historical Data Availability page added for providers; developer portal settings and access model documented; structured logging documented as the backend default; Samsung Health production requirements documented; data migrations moved to Developer Guides; contributing guidelines and PR template rewritten.
Deprecations and Removals
Removed: Garmin backfill cancel and retry endpoints. Garmin allows one request per timeframe per type and answers 409 afterwards, so retry could never do anything useful. The periodic GC task is gone too: it accounted for roughly 15% of worker log lines and in practice never cleared anything, since the per-type timeout and the lock TTL already cover those cases.
Deprecated: Garmin backfill GET status. Stop using it; it will be removed in a future release.
Deprecated: Whoop recovery_score in the recovery summaries endpoint, superseded by the RMSSD and SDNN split.
Upgrading
docker compose pull
docker compose up -d
Migrations run automatically on startup.
Nightly users, read this. If you updated between 2026-09-07T17:46:43Z and 2026-09-08T14:55:55Z, you may have pulled a version containing a faulty migration. Check whether your workout_details table has the entry_source, intensity, and label columns. If not, add them manually:
ALTER TABLE workout_details
ADD COLUMN IF NOT EXISTS entry_source VARCHAR(32),
ADD COLUMN IF NOT EXISTS intensity VARCHAR(10),
ADD COLUMN IF NOT EXISTS label VARCHAR(255);
API keys are hashed by a migration that runs in place, so existing integrations keep working without action. From this release onward, a newly created or rotated key is shown once and cannot be read back afterwards.
Full changelog: github.com/the-momentum/open-wearables/releases/tag/0.8.0
Frequently Asked Questions
Do I need to do anything about the hashed API keys before upgrading?
No. The migration hashes existing keys in place, so current integrations keep working. The change applies going forward: a key created or rotated after this release is displayed once and cannot be read back afterwards.
Why is my Withings integration capped at 10 users?
That cap comes from Withings, not Open Wearables. Withings creates notification subscriptions per user rather than once per application, and applications without compliant webhooks are limited to 10 linked users. Webhook endpoints must use HTTPS on a public hostname, ports 80 or 443 only. The default sync path polls the Measure and Sleep APIs, so polling works without the webhook setup.
Does Withings give me sleep stages and HRV?
Sleep totals yes, including duration, efficiency, and deep, light, REM and awake totals. The sleep stage timeline (hypnogram) is not supported, and neither are ECG or HRV.
Should I turn on SDK_PAYLOAD_S3_OFFLOAD?
Only if SDK upload volume is putting pressure on your Redis broker. It is off by default and nothing changes for deployments that leave it that way. The payload was already being written to S3, so enabling it removes a duplicate copy rather than adding a write.
Where do I see sync history in the UI?
Nowhere yet. 0.8.0 exposes it through the API only. The frontend picks it up in its next iteration.
I used the Garmin backfill retry endpoint. What replaces it?
Nothing, and nothing needs to. Garmin allows one backfill request per timeframe per type and answers 409 afterwards, so retry could never do anything useful. The per-type timeout and lock TTL cover the cases the removed controls were handling. GET status still works but is deprecated.
Is the 3.7x ingestion speedup something I need to configure?
No. The bulk upsert rewrite applies automatically. The dedicated Celery queue for XML imports is part of the same change.
My Whoop integration reads recovery_score. Is it gone?
Not gone, deprecated in the recovery summaries endpoint. HRV is now split into RMSSD and SDNN as distinct metrics, and those are what to build on going forward.
Questions or feedback? Open an issue on GitHub or join the Discord community.