We encourage you to use our official Docker images:
- backend: https://hub.docker.com/r/themomentum/open-wearables-backend
- frontend: https://hub.docker.com/r/themomentum/open-wearables-frontend
Highlights
This release splits the Google integration into two providers, renames the energy series to active_energy, and fixes a long list of API inconsistencies around date bounds, filters and pagination.
Caution
Breaking changes in 0.9.0. Read this list before upgrading.
- Google is now two providers.
googleis replaced bygoogle_health(cloud OAuth, Google Health API) andhealth_connect(Android SDK). Outgoing webhooks carry the new slugs insource.provider,connection.created/connection.revokedand sync events, so anything filtering onprovider == "google"stops matching. A data migration runs automatically on startup. energyis renamed toactive_energyin every response body, and the granular webhook eventseries.energy.createdbecomesseries.active_energy.created. Requests still accepttypes=energyuntil 1.0. Webhook subscribers need the migration script below.- Google Health API rollup is disabled. A provider with
data_granularityset to anything other thanrawnow fails those data types withUnsupportedGranularityErrorinstead of writing duplicated, inflated rows. - Existing Google Health API energy data is wrong and has to be purged with an optional script, then re-synced.
- Date-only
end_date/end_timenow includes the whole end day. If you compensated by passing the next day, you will get one extra day back. /events/sleepno longer returnssleep_stage_intervalsby default. Pass?include=stages. Unreported stages are nownullinstead of0./timeseriesreturnstotal_count: nullon cursor pages and on every aggregated (resolution != raw) read.resolutionon/timeseriesis no longer ignored. Requests that passed it and silently got raw samples now get aggregated buckets.
Google Health and Health Connect are now two providers
google covered two unrelated integrations under one slug: data pushed from Android through the mobile SDK, and the Google Health API cloud OAuth flow. They now have their own provider identities, their own connection rows and their own line on the coverage matrix.
| Old | New | What it is |
|---|---|---|
google
| google_health
| Google Health API, server side OAuth, REST pulls and notify-only webhooks |
google
| health_connect
| Health Connect data uploaded through the mobile SDK |
GET /api/v1/users/{user_id}/connections
{ "data": [ { "provider": "google_health", "status": "active" }, { "provider": "health_connect", "status": "active" } ] }Filters follow the same names: ?provider=google_health, ?provider=health_connect.
What keeps working
- Webhooks already registered with Google against
/api/v1/providers/google/webhooksstill deliver. Both/google/and/google_health/paths are served; new registrations use/google_health/. - Existing user connections with periodic sync keep running with no action.
- The mobile SDK still sends
googleas the provider name in the payload body for Health Connect.googleis kept as an alias forhealth_connect.
What breaks
Warning
Outgoing webhooks now send google_health or health_connect in provider. There is no alias here because it would be ambiguous. Affected payloads: connection.created, connection.revoked, source.provider on data events, and sync events. Update any consumer that filters on provider == "google".
Raw payload storage in S3 also splits into two provider paths instead of one google prefix. This only matters if you read raw payloads for debugging.
New OAuth path and GOOGLE_LEGACY_OAUTH_PATH
The OAuth redirect URI must match what is registered in Google Cloud, so the path is behind a flag:
GOOGLE_LEGACY_OAUTH_PATH=true # default, sends the old /api/v1/oauth/google/callback
GOOGLE_LEGACY_OAUTH_PATH=false # sends /api/v1/oauth/google_health/callback
Existing deployments need no change: the default keeps your registered client working. Both inbound paths are active, the flag only controls the redirect_uri Open Wearables sends to Google.
New integrations, and anyone who wants to be on the supported path before 1.0, should register {API_BASE_URL}/api/v1/oauth/google_health/callback in Google Cloud (Google accepts several redirect URIs, so both can coexist during the migration) and set GOOGLE_LEGACY_OAUTH_PATH=false.
Note
The legacy path and the flag are removed in 1.0.
Google Health API data corrections
Rollup is disabled
The windowed rollUp operation anchors its buckets to the requested range start, so every lookback or manual sync rewrote the same hour at a different timestamp:
{"timestamp": "2026-09-08T08:01:30Z", "value": 70.728}
{"timestamp": "2026-09-08T08:01:33Z", "value": 70.728}It is now off. With the default data_granularity = raw nothing changes. Set to hourly or daily, the affected data types are skipped, the run is marked partial, and you get this in the logs and in Sentry:
UnsupportedGranularityError
Google Health data_granularity 'hourly' needs the windowed rollUp operation, which is disabled (#1577). Its data types were skipped; set the provider's granularity to 'raw' to resume them. Sleep and derived daily totals are unaffected.
Caution
If google settings have data_granularity set to anything other than raw, change it back before upgrading. Sleep sessions and derived daily totals sync either way.
Active and basal energy
Total calories used to be written into the energy series, which means active energy, so values were inflated by BMR and multiplied by repeated syncs. Active energy now comes from the native active-energy-burned intervals (list / reconcile), tagged with an external_id, and basal energy is derived per civil day as total calories minus active energy from dailyRollUp, stored as a daily total.
Migration: purge the bad energy rows (optional, manual)
Run once after deploying and before any historical re-sync. It deletes untagged legacy rows in the live table, plus every archived Google Health API energy bucket (the archive carries no per-row marker, and total-calories was its only writer before this fix).
uv run python scripts/data_migrations/purge_google_total_calories_energy.py --dry-run
uv run python scripts/data_migrations/purge_google_total_calories_energy.py--batch (default 50000) commits per batch so no single transaction holds locks; --skip-archive if archival already ran over re-synced data. Not wired into startup, because it removes data only a re-sync can bring back.
New data types and other fixes
- New series ingested:
heart_rate_variability_rmssd(fromdaily-heart-rate-variability),vo2_max,oxygen_saturation(fromdaily-oxygen-saturation). Webhook subscriptions should include the twodaily-types. - Zero-valued samples are no longer stored, so HRV and SpO2 averages stop being dragged toward 0, and a day with no activity has no row instead of a
0row. - Daily series (resting heart rate, respiratory rate, daily HRV, daily SpO2) are written with
is_daily_total: null, in line with every other provider, so they now appear in bucketed/timeseries?resolution=...reads. Rows written before this release keepis_daily_total: trueand stay excluded. - Live pulls import overnight sleep sessions (matched on session end, not start) and the previous day's daily totals, so the first pull after upgrading may bring in one extra night and one extra day.
- Each data type commits on its own, so a failure in one no longer discards the others.
- A malformed or error-shaped page from Google fails that metric and marks the run
partial, instead of reading as an empty window and reportingsuccess.
Sync reliability and new settings
| Variable | Default | Purpose |
|---|---|---|
PROVIDER_REQUEST_TIMEOUT_SECONDS
| 30
| Per-request timeout for provider API calls, previously hard coded |
LINKED_SYNC_PULL_LEASE_SECONDS
| 120
| Lease for the linked-account pull lock, renewed while the sync runs |
Both optional, defaults keep current behaviour.
linked_sync:<provider>:<account>:pull:primary now expires around two minutes after its holder dies instead of up to four hours, so a manual redis-cli DEL after a worker restart is no longer needed. The Garmin backfill lock keeps its 4 hour TTL.
A linked-account pull that finds another profile syncing the same account now records a run with status: "skipped", items_processed: 0 and metadata.skipped: true in GET /users/{user_id}/sync/runs, instead of recording nothing and reporting success. last_synced_at is not advanced on a skip, so the primary's fan-out covers the full gap.
energy is now active_energy
energy sat next to basal_energy and looked like the total. It was active energy only, so it is renamed. The stored series id is unchanged (81), no data moves, and series_type_definition.code is rewritten by the existing startup seed.
Requests still accept the old name
GET /timeseries?...&types=energy -> 200, "type": "active_energy"
GET /timeseries?...&types=active_energy -> 200, "type": "active_energy"
GET /timeseries?...&types=nonsense -> 400
Responses always return the new name
Warning
Anything matching the literal "energy" in a response body breaks: type on /timeseries, the key in /summaries/data and in /summaries/data/timeline?group_by=series_type, and code in /meta/coverage. Request values keep working until 1.0.
GET /summaries/data -> "series_type_counts": { "active_energy": 905 }
GET /summaries/data/timeline?group_by=series_type -> series key "active_energy"
GET /meta/coverage -> "code": "active_energy"The granular webhook event is renamed too: series.energy.created becomes series.active_energy.created, and series_type in the payload follows. The group event calories.created is unchanged, so broad subscriptions keep receiving active energy whether or not you run the migration.
Migration: webhook subscriptions (optional, manual, two phases)
A Svix endpoint filters on event-type names, so a subscription to series.energy.created survives the rename intact and simply stops matching. No error on either side, just silence.
Both phases run after the deploy, back to back:
# 1. deploy 0.9, which registers series.active_energy.created with Svix on startup
# 2. append the new name next to the old one
uv run python scripts/data_migrations/rename_energy_webhook_event.py --phase=add --dry-run
uv run python scripts/data_migrations/rename_energy_webhook_event.py --phase=add
# 3. drop the dead old name
uv run python scripts/data_migrations/rename_energy_webhook_event.py --phase=remove--phase=add only appends, never removes, and --phase=remove refuses to empty an endpoint's filter list. Only one event name is ever dispatched, so nobody gets duplicates while both names sit on an endpoint. --sleep throttles writes against Svix rate limits.
Customers who manage Svix endpoints from their own IaC should rename the filter on their side, since a redeploy of their config would undo the patch. Clients can check what they are subscribed to:
GET /api/v1/webhooks/endpoints
[ { "id": "ep_...", "filter_types": ["series.active_energy.created"] } ]API corrections and new filters
Date-only end bounds now include the whole day
Caution
A bare end_date / end_time resolves to the start of the next day, so the end day is included. Clients that worked around the old behaviour by passing the next day will get one extra day. An explicit end_time=...T00:00:00Z now stops at midnight instead of silently including the whole day.
GET /events/workouts?start_date=2026-07-13&end_date=2026-07-13
{ "data": [ { "type": "running", "start_time": "2026-07-13T07:01:00Z", "distance_meters": 8200.0 } ],
"pagination": { "has_more": false, "total_count": 1 } }Previously this returned nothing. The same applies to /events, /summaries, /summaries/data/timeline, /timeseries and /health-scores. /health-scores with start_date == end_date returned 500 from the start < end validator and now returns a normal response.
Bucketed /timeseries reads also accept date-only bounds, which returned 500 before:
GET /timeseries?start_time=2026-07-13&end_time=2026-07-13&types=heart_rate&resolution=1min
{ "data": [ { "timestamp": "2026-07-13T07:01:00Z", "type": "heart_rate", "value": 128.0, "unit": "bpm" } ] }/timeseries: resolution now works
resolution was accepted and silently ignored; the endpoint always returned raw samples. Buckets are aggregated in SQL per (bucket, data source, series type), using each type's method: average for rates, sum for counters.
GET /timeseries?start_time=2026-05-01T08:00:00Z&end_time=2026-05-01T08:05:00Z&types=heart_rate&resolution=1min
{ "data": [
{ "timestamp": "2026-05-01T08:00:00Z", "value": 100.0, "source": { "provider": "garmin" } },
{ "timestamp": "2026-05-01T08:00:00Z", "value": 120.0, "source": { "provider": "whoop" } },
{ "timestamp": "2026-05-01T08:01:00Z", "value": 150.0, "source": { "provider": "garmin" } } ],
"pagination": { "has_more": false, "total_count": null },
"metadata": { "resolution": "1min", "sample_count": 3 } }Counters are summed, not averaged: four samples of 15 steps in one minute return 60.0. Valid values are raw, 1min, 5min, 15min, 1h; anything else returns 400. Cursors carry the bucket start and a bucket is never split across pages.
Warning
Aggregated reads exclude rows flagged is_daily_total, because bucketing a whole day's total inside one minute would be wrong. Per-day aggregation stays in /summaries, which resolves the day in the user's local time. total_count is null for aggregated reads. metadata.resolution is now populated, it was always null before.
/timeseries: source filters and priority selection
provider, source, device_model and data_source_id now all reach the query. data_source_id was declared but applied nowhere, so it silently returned every source.
GET /timeseries?...&types=heart_rate&provider=garmin
GET /timeseries?...&types=heart_rate&source=garmin
GET /timeseries?...&types=heart_rate&device_model=Whoop%204.0
GET /timeseries?...&types=heart_rate&data_source_id=aaaa1111-0000-4000-8000-000000000001
The filters narrow the query itself, so total_count reflects them, and they compose with resolution. data_source_id narrows within the caller's own sources and never widens past the user_id check: a source belonging to another user returns 200 with an empty list.
filter_by_priority=true (off by default, matching /events/sleep) keeps a single data source per series type, using the same provider and device-type ranking that sleep sessions and summaries already use. A user wearing a watch and a band no longer gets a sawtooth between two devices' calibrations:
GET /timeseries?...&types=heart_rate&limit=4&filter_by_priority=true
{ "data": [
{ "timestamp": "2026-05-01T08:00:00Z", "value": 142.0, "source": { "provider": "garmin", "device_type": "watch" } },
{ "timestamp": "2026-05-01T08:00:01Z", "value": 142.0, "source": { "provider": "garmin", "device_type": "watch" } } ],
"pagination": { "has_more": false, "total_count": 3 } }Selection is per series type, so a watch that outranks a band still loses the one series only the band records. Ranking runs over the source filters above, so combining them picks the best source among the ones you asked for.
/timeseries: total_count is computed once per run
get_samples ran a full COUNT over the largest table in the system on every page, always returning the same number. It is now taken on the first page only and is null on any page reached by cursor. A keyset page cannot change the total, so keep the first value client side.
GET /timeseries?...&limit=5 -> { "has_more": true, "total_count": 3000000 }
GET /timeseries?...&limit=5&cursor=MjAy... -> { "has_more": true, "total_count": null }/events/sleep: stage intervals are opt-in
Caution
sleep_stage_intervals is no longer returned by default. Pass ?include=stages to get it back, the same way workouts expose zones and segments. Unreported stages now come back as null instead of 0, so a provider that does not measure deep sleep no longer reads as "0 minutes of deep sleep".
GET /events/sleep?start_date=2026-06-01&end_date=2026-06-30
{ "id": "4f1c...", "duration_seconds": 28800, "sleep_duration_seconds": 27000,
"time_in_bed_seconds": 28200, "efficiency_percent": 93.5,
"stages": { "deep_minutes": null, "light_minutes": 300, "rem_minutes": null, "awake_minutes": 30 },
"sleep_stage_intervals": null }GET /events/sleep?start_date=2026-06-01&end_date=2026-06-30&include=stages
{ "sleep_stage_intervals": [ { "stage": "light", "start_time": "2026-06-10T23:00:00Z", "end_time": "2026-06-10T23:40:00Z" },
{ "stage": "deep", "start_time": "2026-06-10T23:40:00Z", "end_time": "2026-06-11T00:25:00Z" } ] }time_in_bed_seconds is new and read from the provider's own figure rather than from end minus start. provider, source, device_model and data_source_id are now available on /events/sleep and /events/menstrual-cycles too.
/events/workouts: zones, segments and filters
GET /events/workouts?start_date=...&end_date=...&include=zones
{ "id": "aabbccdd-efaa-4cdd-bb05-1aad78907c8bb", "type": "cycling", "duration_seconds": 2149,
"avg_heart_rate_bpm": 131, "max_heart_rate_bpm": 172,
"hr_zones": { "zones": [ { "zone": 0, "seconds": 180.0, "max_bpm": 111 }, { "zone": 4, "seconds": 69.0, "max_bpm": 185 } ],
"max_hr": 185, "threshold_hr": 166 },
"power_zones": { "zones": [ { "zone": 0, "seconds": 300.0, "max_watts": 137 }, { "zone": 6, "seconds": 19.0, "max_watts": null } ],
"ftp_watts": 250 } }New filters, plus include=segments:
GET /events/workouts?...&record_type=running -> running and trail_running
GET /events/workouts?...&type=running -> running only
GET /events/workouts?...&provider=suunto
GET /events/workouts?...&type=running&include=segments
Missing workout detail fields were added to the endpoint, and the unused WorkoutDetailed schema was removed.
/summaries/data/timeline: provider filter and workout heatmap
group_by=workout_type adds a second heatmap keyed by workout type. Workouts live in event_record, so metric distinguishes the two without changing the response shape. Sleep sessions share that table and are excluded, and a workout counts in the bucket it started in, matching /summaries/data.
GET /summaries/data/timeline?bucket=day&group_by=workout_type&provider=garmin
{ "bucket": "day", "group_by": "workout_type", "series": [
{ "key": "running", "metric": "workouts", "buckets": [ ["2026-06-15", 2] ] },
{ "key": "cycling", "metric": "workouts", "buckets": [ ["2026-06-16", 1] ] } ] }provider is optional and the generated SQL is unchanged without it. It is applied to the archive query too, so archived stretches do not count other providers under the selected one. A provider with no data returns an empty series list, not every series.
Auth, status codes and limits
DELETE /api/v1/users/{user_id}andPATCH /api/v1/users/{user_id}now accept anX-Open-Wearables-API-Keyas well as a developer JWT. The same key that creates a user and purges its provider data could not delete or update that user, which broke the usual "user deleted on our side, delete it in Open Wearables too" flow.- Both
DELETE /users/{user_id}/connections/{provider}and.../connections/{provider}/datadeclare204in OpenAPI. The wire behaviour was already204, but the API reference documented a200with a JSON body. - Maximum page size is
1000across paginated endpoints./summaries/sleepacceptslimitup to 400, matching/summaries/activity.
Dashboard
The sleep and workout summary widgets on the user profile read one page and ignored next_cursor, so any range with more than 100 items showed wrong totals with no hint of truncation. With the 365 day range, "Nights Tracked" read 100 for a window holding 353 nights. The widgets now fetch every item they aggregate.
What's Changed
Data model
API improvements
- feat(backend): optional zones in GET events/workouts by @KaliszS in #1615
- feat(backend): add missing workout details fields to endpoint by @KaliszS in #1616
- fix(backend): accept API key on DELETE and PATCH /users/{user_id} by @bartmichalak in #1618
- fix(backend): declare 204 on both DELETE connection endpoints by @bartmichalak in #1621
- feat(backend): resolution for /timeseries by @KaliszS in #1624
- feat(backend): /timeseries new filters by @KaliszS in #1626
- feat(backend): source filters and priority selection for /timeseries by @KaliszS in #1628
- fix(backend): accept date-only bounds on bucketed /timeseries reads by @KaliszS in #1630
- fix(backend): make date-only end bounds inclusive of the whole day by @nilzen in #1307
- feat(backend): provider filter and workout-type heatmap on the data t… by @KaliszS in #1635
- refactor(backend): set max paging limit to 1000 by @KaliszS in #1636
- feat(backend): provider and exact-type filters on the workout list by @KaliszS in #1637
- perf(backend): count timeseries rows once per pagination run, not onc… by @KaliszS in #1638
- feat(backend): source filters and opt-in stage intervals on sleep events by @KaliszS in #1639
Google Health
- fix: Google Health API active/basal energy ingestion by @czajkub in #1625
- fix: Google Health API improvements by @czajkub in #1571
- refactor: Google
health_connectandgoogle_healthprovider split by @czajkub in #1634 - fix: Disable Google Health API rollup mechanism by @czajkub in #1644
Other
- refactor(backend): remove unused WorkoutDetailed by @KaliszS in #1619
- fix(frontend): fetch every item the profile summary widgets aggregate by @adam8113 in #1466
- feat(mcp): add HTTP transport with bearer/OAuth auth by @Pixsd in #1573
- revert(mcp): HTTP transport with bearer/OAuth auth (#1573) by @bartmichalak in #1640
- refactor(backend): move shared SDK ingestion out of apple/ namespace by @bartmichalak in #1646
Docs & chores
- docs: remove hand-maintained Quick Reference table from API introduction by @bartmichalak in #1623
- chore(backend): set status_code on routes only for non-200 responses by @bartmichalak in #1620
- docs: refresh public roadmap by @bartmichalak in #1629
- chore(backend): drop stale Withings measure type 140 from deferred list by @bartmichalak in #1641
- docs: document API plan limits and align data types with code for Withings integration by @bartmichalak in #1645
- docs: recommend official release images for production in README by @bartmichalak in #1650
- docs: tell AI agents to consider shared abstractions before provider-specific logic by @bartmichalak in #1647
- docs: clean up README and refresh core features by @bartmichalak in #1651
- chore: update sentry-sdk[fastapi] requirement from >=2.68.1 to >=2.69.1 in /backend by @dependabot[bot] in #1656
- chore: bump the npm-minor-patch group in /frontend with 13 updates by @dependabot[bot] in #1654
- chore: bump the python-minor-patch group across 1 directory with 5 updates by @dependabot[bot] in #1655
- docs: add examples of showing that a change works to the PR guidelines by @bartmichalak in #1653
- docs: add GitHub issue templates by @bartmichalak in #1660
- docs: remove duplicate security link from issue chooser by @bartmichalak in #1662
- chore: update OpenWearables to 0.8.1 & upgrade Svix to v2 by @KaliszS in #1665
- chore: update OpenWearables to 0.9 by @KaliszS in #1666
New Contributors
Full Changelog: 0.8.0...0.9.0