Back to blog

Open Wearables 0.9.0: Google split into two providers, energy renamed, and a long list of API corrections

Open Wearables Team · · 13 min read

Open Wearables 0.9.0 is available. It splits the Google integration into two providers, renames the energy series to active_energy, disables the Google Health API rollup that was writing duplicated rows, and fixes a long list of API inconsistencies around date bounds, filters, and pagination.

Key takeaways

  • Three new Google Health API series: RMSSD heart rate variability, VO2 max, and oxygen saturation
  • Zero-valued samples are no longer stored, so averages stop being dragged toward zero
  • resolution on /timeseries now aggregates into buckets instead of being ignored
  • Source filters on /timeseries reach the query, plus optional priority selection per series type
  • Workouts expose heart rate and power zones and segments on request; sleep exposes stage intervals the same way
  • Sleep sessions carry a new time-in-bed figure taken from the provider rather than inferred
  • Two optional settings: PROVIDER_REQUEST_TIMEOUT_SECONDS and LINKED_SYNC_PULL_LEASE_SECONDS, both with defaults that preserve current behaviour
  • Maximum page size across paginated endpoints is now 1000

Breaking changes

Breaking changes in 0.9.0. Read this list before upgrading.

  • Google is now two providers. google is replaced by google_health (cloud OAuth, Google Health API) and health_connect (Android SDK). Outgoing webhooks carry the new slugs in source.provider, connection.created / connection.revoked and sync events, so anything filtering on provider == "google" stops matching. A data migration runs automatically on startup.
  • energy is renamed to active_energy in every response body, and the granular webhook event series.energy.created becomes series.active_energy.created. Requests still accept types=energy until 1.0. Webhook subscribers need the migration script below.
  • Google Health API rollup is disabled. A provider with data_granularity set to anything other than raw now fails those data types with UnsupportedGranularityError instead 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_time now includes the whole end day. If you compensated by passing the next day, you will get one extra day back.
  • /events/sleep no longer returns sleep_stage_intervals by default. Pass ?include=stages. Unreported stages are now null instead of 0.
  • /timeseries returns total_count: null on cursor pages and on every aggregated (resolution != raw) read.
  • resolution on /timeseries is 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. The cloud integration, with its server-side OAuth, REST pulls and notify-only webhooks, becomes google_health. Health Connect data uploaded through the mobile SDK becomes health_connect. Both appear separately in a user's connections and work as filter values under their own names.

What keeps working

Webhooks already registered against the /google/ path still deliver, since that path is aliased to the cloud provider; new registrations use /google_health/. Existing connections with periodic sync keep running. The mobile SDK still sends google in its payload body, and that alias is permanent rather than a migration window, so deployed app versions do not break.

What breaks

Outgoing webhooks now send google_health or health_connect in the provider field, across connection events, data event sources, and sync events. There is no alias here, because telling the two apart is the entire point of the split.

The alias covers the webhook path only. Passing provider=google as a data filter, or calling a sync route under the old slug, is rejected with a 400 naming google_health rather than silently resolving to one half of the split. Raw payload storage in S3 also splits into two paths, which matters only if you read raw payloads for debugging.

The OAuth path and GOOGLE_LEGACY_OAUTH_PATH

The OAuth redirect URI must match what is registered in Google Cloud, so the path sits 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, and both inbound paths are served either way. New integrations should register the google_health callback in Google Cloud and set the flag to false; Google accepts several redirect URIs, so both can coexist. 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 rather than a fixed wall-clock grid, so the same hour requested from two different starting points comes back stamped at two different timestamps. Every lookback and every manual sync wrote another copy.

It is now off. With the default data_granularity = raw nothing changes. Set to hourly or daily, the affected types are skipped, the run is marked partial, and this lands 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.
          

If your 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 basal metabolic rate and multiplied by repeated syncs over the same window.

Active energy now comes from the native active-energy-burned intervals and is tagged with an external id so it can be identified later. Basal energy is derived per civil day as total calories minus active energy, and 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, since the archive carries no per-row marker.

            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
          

The --batch flag defaults to 50000 and commits per batch, so no single transaction holds locks; add --skip-archive if archival already ran over re-synced data. It is not wired into startup, because it removes data only a re-sync can bring back.

New data types and other fixes

Three series previously not stored at all are now ingested: RMSSD heart rate variability, VO2 max, and oxygen saturation, the first and last from Google's daily endpoints. Webhook subscriptions should include those two daily types. Zero-valued samples are no longer stored, so HRV and saturation averages stop being pulled toward zero.

Daily series are now written without the daily-total flag, in line with every other provider, so they appear in bucketed reads; rows written before this release keep the old flag and stay excluded. Live pulls import overnight sleep sessions matched on session end rather than start, plus the previous day's totals, so the first pull may bring one extra night and day. Each data type commits on its own, and a malformed page from Google fails that metric and marks the run partial instead of reporting success on an empty window.

Sync reliability and new settings

Two optional settings become configurable, both with defaults that preserve current behaviour: PROVIDER_REQUEST_TIMEOUT_SECONDS (30) for the per-request provider timeout, previously hard coded, and LINKED_SYNC_PULL_LEASE_SECONDS (120) for the linked-account pull lock, renewed while the sync runs.

That lock now expires around two minutes after its holder dies rather than up to four hours, so clearing it by hand after a worker restart is no longer necessary. The Garmin backfill lock keeps its four hour lifetime. A linked-account pull that finds another profile already syncing the same account records a run marked skipped instead of reporting success, and does not advance the last-synced timestamp, so the primary sync still 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 rename applies throughout: the series type name in the database updates itself via the existing startup seed, and the stored series id is unchanged, so no data moves.

Requests keep a types=energy alias until 1.0, but it is not recommended and you should migrate off it. The breaking part is responses. Anything matching the literal string energy in a response body breaks: the type field on time series reads, the keys in the data summary and timeline, and the code in the coverage endpoint.

The granular webhook event is renamed alongside it. The group event covering calories 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 the old event survives the rename intact and simply stops matching. No error on either side, just silence.

Note that the release notes place both phases after the deploy, while the script's own documentation places the first phase before it, so that no window exists where the dispatched name is missing from a subscriber's filter. The script only patches endpoint filters; the event type itself is registered with Svix at application startup. If your Svix instance rejects a filter naming an unregistered event type, run the first phase after the deploy, otherwise before. Either way, run the second phase afterwards.

            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
uv run python scripts/data_migrations/rename_energy_webhook_event.py --phase=remove
          

The add phase only appends; the remove phase refuses to empty a filter list rather than turning that endpoint into a firehose. Only one event name is ever dispatched, so nobody receives duplicates while both sit on an endpoint, --sleep throttles against Svix rate limits, and the whole thing is idempotent.

If you manage Svix endpoints from your own infrastructure-as-code, rename the filter there instead, since a redeploy would undo the patch.

API corrections and new filters

Date-only end bounds now include the whole day

A bare end_date or end_time resolves to the start of the next day, so the end day is included. Asking for a single day by passing the same date twice previously returned nothing; now it returns that day. An explicit midnight timestamp still stops at midnight, for anyone who wants an exclusive bound.

This applies across events, summaries, the data timeline, time series and health scores, the last of which also stops returning a server error when start and end are equal. Bucketed time series reads accept date-only bounds too, which also used to fail.

resolution on /timeseries now works

resolution was accepted and silently ignored, and the endpoint always returned raw samples. Buckets are now aggregated in the database per bucket, data source and series type: averaged for rates such as heart rate, summed for counters such as steps, so four samples of fifteen steps within one minute return sixty rather than fifteen.

Valid values are raw, 1min, 5min, 15min and 1hour; anything else returns a 400. Cursors carry the bucket start, so a bucket is never split across pages. Aggregated reads exclude rows flagged as daily totals, since bucketing a whole day's total inside one minute would be wrong; per-day aggregation stays in the summaries endpoints. The resolution field in response metadata is now populated, where it was always null before.

Source filters and priority selection

Provider, source, device model and data source id now all reach the query. Data source id was declared in the API but applied nowhere, so it silently returned every source, which is worse than an error. The filters narrow the query itself, so the total count reflects them, and they compose with resolution. Data source id never widens past the user check: another user's source returns an empty list.

Priority selection, off by default and matching how sleep already behaves, keeps a single data source per series type using the same ranking sleep sessions use, so a user wearing both a watch and a band no longer produces a sawtooth between two calibrations. Selection runs per series type, so a watch that outranks a band still loses on the one series only the band records.

Pagination totals are computed once

Reading time series 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 comes back null on cursor pages and aggregated reads, so hold the first value client side.

Sleep stage intervals are opt-in

The interval timeline is no longer returned by default. Pass include=stages to get it, the same way workouts expose zones and segments.

More consequentially, stages a provider does not measure now come back as null rather than zero. A device that does not track deep sleep used to read as zero minutes of deep sleep, which is a different claim entirely, and anything built on top inherited that error. Sleep sessions also carry a new time-in-bed figure taken from the provider rather than inferred, and the four source filters work on sleep and menstrual cycle events too.

Workouts: zones, segments and filters

Workouts expose heart rate and power zones on request, each carrying per-zone durations plus the thresholds they were computed against, and segments the same way. Two filters distinguish a broad from an exact match: record_type=running also returns trail running, while type=running returns running only. Filtering by provider is available too.

Data timeline: provider filter and workout heatmap

Grouping the timeline by workout type adds a second heatmap. Workouts and data points share a table, so a metric field distinguishes the two without changing the response shape; sleep sessions are excluded, and a workout counts in the bucket it started in. A provider filter is now available and applies to the archive query too.

Auth, status codes and limits

Deleting and updating a user now accept an API key as well as a developer token: the same key that creates a user could not delete it, which broke the usual flow of removing a user on both sides at once. Both connection deletion endpoints now declare a 204 in OpenAPI, matching wire behaviour that was already 204. Maximum page size is 1000 across paginated endpoints, sleep summaries included.

Dashboard

The sleep and workout summary widgets on the user profile read one page and ignored the cursor, so any range with more than a hundred items showed wrong totals with no hint of truncation: on the year-long range, nights tracked read 100 for a window holding 353 nights. They now fetch every item they aggregate.

Other changes

Shared SDK ingestion moved out of the Apple namespace, and an MCP HTTP transport with bearer and OAuth auth was added and then reverted within this release.

On the documentation side: the quick reference table was removed from the API introduction, the public roadmap refreshed, Withings plan limits documented, official release images recommended for production in the README, and GitHub issue templates added. Svix was upgraded to v2 alongside the usual dependency bumps.

Upgrading

            docker compose pull
docker compose up -d
          

Migrations run automatically on startup, including the Google provider split.

Before you upgrade, check whether your Google settings have data_granularity set to anything other than raw, and change it back if so.

After you deploy: update any webhook consumer filtering on the old google value; run the energy purge script before any historical re-sync if you ingested Google Health API energy data; run the two-phase webhook rename if you subscribe to the granular energy event; and check clients that worked around exclusive end bounds, read sleep stage intervals by default, or relied on a total count past the first page.

Full changelog: github.com/the-momentum/open-wearables/releases/tag/0.9.0

Frequently asked questions

Do my existing Google connections stop working after the split?

No. Connections with periodic sync keep running, webhooks registered against the old path still deliver, and the mobile SDK can keep sending google because that alias is permanent. What breaks is outgoing webhooks, which now carry google_health or health_connect with no alias.

Do I have to run the energy purge script?

Only if you ingested Google Health API energy data before this release, in which case those values are inflated by basal metabolic rate and multiplied by repeated syncs. It is optional and manual, deliberately not wired into startup, because it removes data only a re-sync can bring back.

My integration reads energy in responses. Does it break?

Yes. Responses are the breaking part: time series reads, the data summary, the timeline and the coverage endpoint all return active_energy now. Requests keep a types=energy alias until 1.0, but it is not recommended, so treat it as time to migrate rather than a setting to rely on. The series type name in the database updates itself on startup, and the series id is unchanged, so no data moves.

Why is my Google Health API sync reporting partial after upgrading?

Because data_granularity is set to something other than raw. The rollUp operation those types needed is disabled, so they are skipped and the run is marked partial, with UnsupportedGranularityError in the logs. Set granularity back to raw to resume them.

Questions or feedback? Open an issue on GitHub or join the Discord community.

Open Wearables

Open Wearables is an open-source platform that connects your application to wearable and health data providers through a single API. One normalized data model, multi-provider support in a single self-hosted deployment, no per-user fees, MIT licensed, and health intelligence built in: normalized recovery, sleep, activity and biometric data ready for your product layer.

We offer custom deployment and integration support through Momentum. If your team needs help getting to production faster, we can set up and configure Open Wearables as part of a managed engagement. Let's talk.

Book a demo to see how Open Wearables fits your use case.

See related articles

Open Wearables 0.8.0: Withings, sync history, and faster ingestion

Open Wearables 0.7.0: Google Health API, Sensor Bio, and a faster Admin UI

Google Health Connect integration for Android developers

Never miss an update

Stay updated with the latest in open wearables, developer tools, and health data integration.

Join our Community. No spam ever.