Open Wearables 0.10.0: new dashboard, nutrition data and sync-aware webhooks
Open Wearables 0.10.0 is available. It rebuilds the dashboard in SvelteKit and adds nutrition data with 42 new series types. Outgoing webhooks now carry the sync they came from. Calories and data sources are more accurate across providers. The release merges 123 pull requests in total.
Several changes are breaking. Most of them touch a specific provider or an edge case, but read the list below before upgrading.
Key takeaways
- New dashboard in SvelteKit with light and dark themes and a mobile layout, plus period presets and provider filters on the user profile
- 42 new nutrition series types plus a meal event type, with GET and DELETE endpoints for meals
- Every data webhook carries a sync block that tells a backfill from live sync, plus two new per-data-type sync events
- New total_energy series for providers that report only a total. Basal energy is now stored for Garmin, Polar, Oura and SensorBio
- Data sources are matched by device ID, then by app bundle ID and model, so one device no longer splits into several sources
- New personal-record endpoint, so max heart rate no longer defaults to 190 bpm
- Sentry stops receiving health data and PII by default
- Anonymous daily usage telemetry, off with TELEMETRY_ENABLED=false or DO_NOT_TRACK=1
Breaking changes
Breaking changes in 0.10.0. Most of them probably don't affect you, since they only touch specific providers or edge cases. Read the list carefully before upgrading.
- active_calories_kcal is now null instead of 0 when a day has no active energy, for every provider. total_calories_kcal in /summaries/activity is null when a day has neither total nor basal energy. (#1801)
- Whoop cycle energy is stored as the new total_energy series instead of active_energy, because it includes BMR. Webhooks for Whoop cycles now send series.total_energy.created instead of series.active_energy.created. A startup migration relabels stored rows and drops 0 kJ no-wear cycles. (#1801)
- Ultrahuman active minutes move from active_time to exercise_time. Outgoing webhooks send series.exercise_time.created instead of series.active_time.created. active_minutes in /summaries/activity falls back to the step-based estimate for Ultrahuman users. A startup migration relabels stored rows. (#1765)
- Ultrahuman HRV moves from heart_rate_variability_sdnn to heart_rate_variability_rmssd, as confirmed by Ultrahuman. A startup migration relabels stored rows. (#1686)
- Workouts from the mobile SDK and Apple XML import that report no calories now have null calories instead of 0. A startup migration clears the stored zeros. (#1874)
- SDK refresh tokens of deleted applications are removed. Their users get a 401 on the next refresh. The SDK reports it as an auth error. See Handle expired sessions. (#1841)
- Outgoing webhook payloads are kept in Svix for 7 days instead of 90. Change it with OUTGOING_WEBHOOK_PAYLOAD_RETENTION_DAYS (5 to 90). Payloads already stored keep their 90-day expiry. (#1719)
- 24/7 sync results have a new, typed shape. data_247 is now an object with status, item counters and one entry per provider task. It replaces the flat map of task names to row counts along with the success and saved flags. (#1501)
The 24/7 change shows up in the sync endpoint response (details.data_247) and in the sync.completed and sync.failed webhooks (metadata.params.data_247). Sync status events use the new shape too. A sync where every 24/7 task fails now returns success: false and emits sync.failed. Error fields carry a short code such as http_401 or IntegrityError instead of the exception text. In params.workouts, error is renamed to error_code.
Check for SDK users of deleted applications
Before upgrading, this query lists deleted applications whose SDK users still hold active refresh tokens:
SELECT rt.app_id,
count(*) AS active_tokens,
count(DISTINCT rt.user_id) AS users,
max(coalesce(rt.last_used_at, rt.created_at)) AS last_activity
FROM refresh_token rt
LEFT JOIN application a ON a.app_id = rt.app_id
WHERE rt.token_type = 'sdk'
AND rt.revoked_at IS NULL
AND a.id IS NULL
AND split_part(rt.app_id, ':', 1) NOT IN ('admin', 'invite')
GROUP BY rt.app_id
ORDER BY last_activity DESC;
Apps that handle the SDK auth error by getting new tokens from their backend and passing them to updateTokens() recover on their own. This works as long as that backend uses the credentials of an application that still exists.
Longer first startup
The first startup after upgrading can take longer than usual. Data migrations merge duplicated data sources and relabel the rows listed above. Expensive migrations are now recorded in a new data_migration table and run once per instance. More: Data migrations
New dashboard
The dashboard is rewritten in SvelteKit. The layout is cleaner, with light and dark themes or an option to follow the system setting. It also works on phones, with a bottom navigation bar.
Heavy pages load faster. Data Summary opens on the last 30 days by default. Tabs on the user profile have period presets (1W, 1M, 3M, 6M, 1Y) and a provider filter. The Sleep tab can show only night sleep or only naps. It can also limit the view to the highest-priority source. Each record shows which provider and device it came from.
The user profile also got a Meals tab. The Women's Health tab is always visible, empty when there's no data. Data sources are listed in priority order.
Configuration
The image name stays the same. So do port 3000 and VITE_API_URL, which means no configuration change is needed. Developers sign in again once after the upgrade.
VITE_API_URL is now read at runtime, so one image works against any backend without a rebuild. Optionally set API_URL when the frontend can reach the API on an internal address, such as http://app:8000 on the same Docker network. Sign-in sessions live in an HttpOnly cookie, with access tokens refreshed automatically. The frontend needs no Redis.
More: Frontend configuration
Nutrition data
Open Wearables now stores nutrition data. There are 42 new series types: calories and macronutrients, fat broken down into total, saturated, mono- and polyunsaturated and trans, plus fiber, sugar, cholesterol, caffeine and a full set of vitamins and minerals.
Apps that log food as meals also get a new meal event type that carries the meal's title and type along with its nutrients. Nutrition samples logged outside a meal are stored as regular time series.
This is most useful with Apple Health. Food trackers such as MyFitnessPal and Cronometer write what users eat into it, as do dozens of other apps. Connecting Apple Health gets you their data without integrating each app separately. Health Connect covers all 42 nutrition types and Apple Health covers 38. Samsung Health covers 17. The Google Health API nutrition log is supported too.
GET /api/v1/users/{user_id}/events/meals?start_date=2026-10-05&end_date=2026-10-05
DELETE /api/v1/users/{user_id}/events/meals/{meal_id}
The seed generator can create meals too, with a nutrition_focused preset.
Mobile SDK support
For the mobile SDK, nutrition is supported on the backend side only for now. SDK versions that send meals from the phone's health store haven't been released yet. They're coming soon, so follow the iOS SDK and Android SDK repos. Google Health API nutrition works with this release.
More: Nutrition data types
Outgoing webhooks know which sync they came from
Every data webhook now carries a sync block, so a consumer can tell a historical backfill from live sync:
"sync": { "run_id": "backfill_9f3c8ea4", "source": "backfill", "scope": "historical" }
source is one of pull, webhook, sdk, backfill, xml_import or linked_account. run_id joins to GET /sync/history/{run_key}.
Two new events, sync.data_type.completed and sync.data_type.failed, report the result per data type. Each carries item counts plus the written and covered time ranges. Existing payloads only gain the new key.
Calories: total, active and basal
Providers that report only a total with no active/basal split now write to a new total_energy series, with a series.total_energy.created webhook. Today that's Whoop.
Basal energy is now stored for Garmin, Polar, Oura and SensorBio. total_calories_kcal in summaries comes from total_energy first, with active plus basal as the fallback.
Whoop cycle metrics are also anchored on the right local day. Users in negative UTC offsets no longer get two cycles summed into one day.
Stable data sources
One device or app no longer splits into several data sources. A source is now matched by device ID first, then by app bundle ID and model. An existing source gets upgraded when better data arrives.
In practice, renaming an iPhone no longer creates a new source. Upgrading the phone that relays third-party Apple Health data keeps the same source too, as does Samsung sending steps without IDs. A startup migration merges the duplicates already stored.
New device types were added as well, among them chest straps, earbuds, tablets, HR sensors and glucose meters.
More data from existing providers
- exercise_time (moderate plus vigorous minutes) for Garmin, Oura, Withings, Ultrahuman, Polar and Google Health
- Skin temperature deviation for Garmin and Ultrahuman, plus Google Health
- Per-minute step samples for Polar and Withings, with per-minute distance and active energy from Withings as well
- Resting heart rate for Ultrahuman and Withings
- Sleep stage timeline for Withings and SensorBio
- Overnight HRV from the sleep series
- Polar Nightly Recharge HRV and breathing rate stored as time series
- HRV RMSSD from HealthKit (iOS 27+)
- Step count from Whoop /v2/cycle
- Samsung sleep score
More: Data coverage
API additions and fixes
- GET and PUT /api/v1/users/{user_id}/personal-record read and set personal details such as birth date and sex. Until now nothing could write them, so the activity summary always assumed a 190 bpm max heart rate.
- is_nap filter on GET /events/sleep.
- /summaries/sleep and /summaries/recovery page by night or day instead of source row. has_more is now right when several sources recorded the same nights.
- Out-of-range numeric dates (inf, 1e20) return 400 instead of 500.
- Outgoing webhook endpoints are listed past Svix's first page.
Privacy and security
- Sentry no longer receives health data or PII by default. Request bodies and stack-frame locals are no longer sent. Log breadcrumbs are off too. Emails and query values are filtered. SENTRY_SEND_SENSITIVE_DATA=true turns it back on for dev or staging.
- Deleting a user also deletes the payloads of their outgoing webhook messages from Svix.
- Signing in to the portal with the public default password now asks you to change it. With ENVIRONMENT=production you can't skip it. More: Change Password
- config/.env is no longer copied into locally built backend images. Images on Docker Hub never contained it.
Self-hosting
- SMTP for invitation emails. Set SMTP_HOST to use any mail provider; Resend still works as before. When no email delivery is configured, the portal hands you the invite link instead of pretending it was sent. More: Email delivery
- DATA_LIFECYCLE_ENABLED turns off Data Lifecycle (early access). It defaults to true.
- Logs: LOG_FORMAT=legacy|json|text and LOG_LEVEL, both opt-in, plus optional OpenTelemetry log export with OTEL_ENABLED=true. More: Log export
- Containers now stop gracefully: SIGTERM is forwarded to the workers.
- Throttled provider requests back off instead of failing the sync. Switching a provider from webhooks back to periodic pull now deregisters its webhooks.
- Periodic sync skips push-only providers. GET /sync/runs no longer scans every user.
- All providers are seeded at startup, so priorities can be set before the first sync. The Priorities tab shows provider logos.
- The backend runs on Python 3.14. If you build images locally, rebuild them.
- Extension point for add-on modules: separately packaged Python packages can register Celery tasks (periodic ones included) and routers under /api/v1/ext/<name>.
Anonymous usage telemetry
Self-hosted instances now send one small anonymous usage ping a day, plus one on startup. It helps the team decide what to build next and what's safe to deprecate.
Counts are sent as order-of-magnitude buckets and endpoints as route templates. There's no health data or user data in the ping. It also contains no credentials, hostnames, IPs or URLs.
Turn it off with either setting:
TELEMETRY_ENABLED=false
DO_NOT_TRACK=1
More: Telemetry
Upgrading
docker compose pull
docker compose up -d
Migrations run automatically on startup. The first startup can take longer while they merge data sources and relabel rows.
Before you upgrade, read the breaking changes and run the SDK token query if you have SDK users. After you deploy, update webhook consumers that filter on series.active_energy.created for Whoop or series.active_time.created for Ultrahuman. Check any client that reads data_247 or expects 0 instead of null for calories. Clients relying on 90-day payload retention in Svix need the retention setting raised.
We recommend the official Docker images:
- backend: themomentum/open-wearables-backend
- frontend: themomentum/open-wearables-frontend
Full changelog: github.com/the-momentum/open-wearables/releases/tag/0.10.0
What's next
0.11 is coming soon and focuses on further data model improvements on the way to Open Wearables 1.0. If there's something you think 1.0 should include, tell us on Discord or in Discussions. Feedback from teams that have run Open Wearables for a while is especially welcome.
Frequently asked questions
Do I need to change frontend configuration for the new dashboard?
No. The image name, port 3000 and VITE_API_URL are unchanged. Developers sign in again once after the upgrade. API_URL is optional, for when the frontend can reach the API on an internal address.
Why does my Whoop webhook consumer stop receiving active energy events?
Whoop cycle energy now goes to the total_energy series, because it includes BMR. Subscribe to series.total_energy.created instead of series.active_energy.created for Whoop cycles.
Can the mobile SDK send meals yet?
Not yet. Nutrition is supported on the backend. SDK versions that send meals are coming soon in the iOS and Android SDK repos. Google Health API nutrition works with 0.10.0.
Why do some calorie fields return null after upgrading?
A null means no data was recorded, a case that 0 used to hide. active_calories_kcal is null when a day has no active energy. Workouts without reported calories have null calories as well. Treat null as missing data in your client.
How do I turn off telemetry?
Set TELEMETRY_ENABLED=false or DO_NOT_TRACK=1. The ping contains no health data, user data, credentials, hostnames, IPs or URLs.
For 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