Key Takeaways
- The Google integration is split into two providers. They were two unrelated integrations sharing one slug, and they now have separate identities, connection rows, and coverage matrix entries.
- The energy series is renamed to active_energy. It always held active energy only, but it sat next to basal_energy and read like the total.
- Google Health API energy data recorded before this release is wrong. Total calories were being written into the active energy series, so values were inflated by basal metabolic rate and multiplied by repeated syncs.
- A rollup mechanism that quietly duplicated data is disabled. It anchored its time buckets to the requested range start, so every lookback or manual sync rewrote the same hour at a slightly different timestamp.
- A long list of API inconsistencies is fixed: date-only end bounds that excluded the end day, a resolution parameter that was accepted and ignored, filters that were declared but never applied, and a total count that ran a full count over the largest table in the system on every page.
Is Your HealthTech Product Built for Success in Digital Health?
.avif)
Open Wearables 0.9.0 is available, and it is the most breaking release the project has shipped. Before anything else: if you run Open Wearables in production, there are changes here that need a plan rather than a routine docker compose pull.
The exposure is concentrated in three places. Webhook consumers that filter on the google provider value stop matching, because Google is now two providers. Anything reading the literal energy key in a response body breaks, because the series is renamed to active_energy. And Google Health API energy data recorded before this release is wrong, inflated by basal metabolic rate and compounded by repeated syncs, which means a purge and a re-sync rather than an in-place fix.
Beyond those three, several endpoints now behave the way their documentation always claimed. That is a good thing, but it does mean clients built around the old behaviour need checking. The full list is below, then the reasoning behind each change.
Breaking Changes
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.
This is a correctness release. Rather than adding a new provider, it goes back over integrations and endpoints that worked well enough to ship and fixes the places where the platform was returning data that was subtly wrong, or accepting parameters it then ignored.
That is what makes it breaking. It is also what makes it the release that removes the largest amount of quiet guesswork from anything built on top.
One Slug, Two Integrations: Splitting Google
Until this release, google meant two different things depending on where the data came from.
One was the Google Health API: a server-side OAuth flow, REST pulls, and notify-only webhooks. The other was Health Connect: data pushed from an Android device through the mobile SDK. Different auth, different transport, different data shapes, one provider slug.
That collapsed distinction showed up everywhere downstream. A connection row could not tell you which integration a user had actually authorised. The coverage matrix showed one line for two very different sets of capabilities. Anything filtering on provider treated cloud pulls and phone uploads as the same source.
0.9.0 gives them separate identities. What was previously a single google provider is now:
google_health, covering the Google Health API: server-side OAuth, REST pulls, and notify-only webhooks.health_connect, covering Health Connect data uploaded through the mobile SDK.
A data migration runs automatically on startup, so existing connections carry over. Periodic syncs keep running with no action, webhooks already registered with Google against the old path still deliver, and the mobile SDK can keep sending google in its payload body, because that name is kept as an alias for health_connect.
The part that breaks is outgoing webhooks. They now carry google_health or health_connect in the provider field, on connection events, sync events, and the source attached to data events. There is deliberately no alias there, because an alias would be ambiguous: the whole point of the split is that a consumer can tell the two apart. Anything filtering on the old value stops matching and needs updating.
The same strictness applies to your own API calls. Passing the old provider value as a data filter or on a sync route is rejected outright, with an error naming the new value, rather than quietly returning one half of the split. That is the right trade: a failed call is easier to notice than a silently halved dataset.
The OAuth redirect path is handled more gently, because a redirect URI has to match what is registered in Google Cloud and that is not something a platform upgrade should change under you. A flag controls it, defaulting to the legacy path, so existing clients keep working. Both inbound paths are served, so new integrations can register the new callback in Google Cloud and switch the flag over whenever they are ready. The legacy path goes away in 1.0.
Energy Data That Was Wrong, and How to Fix It
This is the correction with real consequences for anything already in production.
The Google Health API integration was writing total calories into the energy series. That series means active energy: what someone burned through movement, over and above resting metabolism. Writing total calories there inflated every value by basal metabolic rate, and because the rollup mechanism rewrote the same window repeatedly, the inflation compounded across syncs.
Active energy now comes from the native active-energy-burned intervals, tagged so it can be identified. Basal energy is derived per civil day as total calories minus active energy, and stored as a daily total. That is the right shape: two distinct series, each meaning what its name says.
Historical data recorded before this fix cannot be repaired in place, only replaced. There is a purge script, run manually after deploying and before any historical re-sync. It is deliberately not wired into startup, because it deletes data that only a re-sync can bring back, and that should be a decision someone makes rather than something an upgrade does silently. It commits in batches so no single transaction holds locks on a large table.
If your product surfaces calorie burn, or feeds it into scoring, coaching, or anything a clinician reads, this is worth planning properly rather than treating as a routine upgrade step.
The Rollup That Duplicated Rows
Related, and the reason the energy inflation compounded.
The Google Health API offers a windowed rollup operation that aggregates samples into buckets. The problem is that it anchors those buckets to the start of the requested range, not to a fixed wall-clock grid. Ask for the same hour from two different starting points and you get the same value stamped at two different timestamps. Every lookback window and every manual sync wrote another copy.
The rollup is now disabled. With the default raw granularity, nothing changes. If a deployment has granularity set to hourly or daily, the affected data types are now skipped rather than duplicated, the run is marked partial, and a clear error naming the cause appears in the logs and in Sentry.
Check this setting before upgrading. If your Google settings specify anything other than raw granularity, set it back to raw, or those data types stop syncing. Sleep sessions and derived daily totals are unaffected either way.
Renaming energy to active_energy
The series was always active energy. The name suggested otherwise, particularly sitting next to basal_energy in the same response, where it read like the total rather than one of two components. That is the kind of ambiguity that produces wrong numbers in someone else's dashboard six months later.
The rename applies throughout, not just at the surface: the series type name in the database updates itself on startup. What does not change is the underlying series id, so no data moves and nothing is rewritten.
Requests keep an alias for the old name until 1.0, but it is not a recommended path and is worth migrating off rather than relying on. The breaking part is responses, where anything matching the literal string breaks: the type field on time series reads, the keys in summary and timeline responses, and the code in the coverage endpoint.
The granular webhook event is renamed alongside it. This deserves particular attention, because of how webhook subscriptions fail. A subscription filtering on the old event name survives the rename intact and simply stops matching. There is no error on either side. It just goes quiet.
The migration is a two-phase script: first append the new event name next to the old one, then drop the old one. Note that the release notes and the script's own documentation disagree on whether the first phase belongs before or after the deploy, so check the script before running it. Only one event name is ever dispatched, so nothing receives duplicates while both sit on an endpoint. Teams managing their webhook endpoints from their own infrastructure-as-code should make the rename there instead, since a redeploy of their config would otherwise undo the patch.
Broader subscriptions to the group-level calories event are unaffected.
Fixing the API's Quiet Lies
The rest of the release is a sweep through endpoints that accepted things they did not honour, or returned things that were not quite true.
Date-only end bounds now include the end day. Asking for a single day by passing the same date as start and end used to return nothing, which is the opposite of what anyone expects. A bare end date now resolves to the start of the next day, so the end day is included. An explicit midnight timestamp still stops at midnight, for anyone who wants an exclusive bound. If you compensated for the old behaviour by passing the next day, drop that workaround or you will get an extra day back. The same endpoint also stopped returning a server error when start and end were equal.
resolution on time series now works. It was accepted and silently ignored, and the endpoint always returned raw samples. Buckets are now aggregated in the database, using the right method per data type: averaged for rates like heart rate, summed for counters like steps. Four samples of fifteen steps in one minute return sixty, not fifteen. If you were passing this parameter and building around raw samples, the shape of what comes back changes.
Source filters now reach the query. Provider, source, device model, and data source id are all applied. One of them was declared in the API but applied nowhere, so it silently returned every source, which is worse than returning an error. There is also new optional priority selection, which keeps a single data source per series type using the same ranking sleep sessions already use. A user wearing both a watch and a band no longer produces a sawtooth between two devices' calibrations.
Pagination totals are computed once. The time series endpoint ran a full count over the largest table in the system on every single page, returning the same number each time. It is now taken on the first page and returned as null on cursor pages, since a keyset page cannot change the total.
Sleep stage intervals are opt-in. The full interval timeline is no longer returned by default and is requested explicitly, matching how workouts expose zones and segments. Just as importantly, stages a provider does not measure now come back as null rather than zero. "Zero minutes of deep sleep" and "this device does not measure deep sleep" are very different statements, and until now the API made them look identical.
Workouts gained optional zones and segments, plus provider and exact-type filters, and a new time-in-bed figure on sleep reads the provider's own number instead of inferring it from start and end times.
The dashboard's summary widgets were reading a single page and ignoring the cursor, so any range holding more than a hundred items showed wrong totals with no indication of truncation. On a year-long range, a window containing 353 tracked nights displayed 100. They now fetch everything they aggregate.
Upgrading
Standard Docker Compose update, with migrations running automatically on startup, including the Google provider split.
Before upgrading: if your Google settings use anything other than raw granularity, change it back.
After deploying: update webhook consumers that filter on the old google provider 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 any client that worked around exclusive date bounds, read sleep stage intervals by default, or relied on a total count on paged reads.
Official Docker images remain the recommended deployment path for backend and frontend, published for both amd64 and arm64. Pin a version for production.
The full changelog is at github.com/the-momentum/open-wearables/releases/tag/0.9.0.
Community
Open Wearables is past 2,500 stars on GitHub, with close to 500 forks. Two contributors made their first contribution in this release, one of them landing the date-bounds fix that had been open for a while.
That is the pattern worth watching: the corrections in this release largely came from people running the platform against real data and noticing where the numbers did not add up.
The project is at github.com/the-momentum/open-wearables. The Discord community is the fastest place to get an answer.
%201.png)

.png)


