Key Takeaways
- Without a normalization layer, adding each new wearable provider means writing separate API clients, handling different authentication flows, mapping provider-specific field names, and converting units manually.
- Open Wearables uses a unified data model built around five components: Events, Time Series, Descriptors, ID Mapping, and Series Type Definitions.
- All data is automatically converted to canonical units: heart rate in bpm, distance in meters, temperature in Celsius, timestamps in UTC.
- Provider sync differences (Garmin webhook-only vs. Polar REST polling vs. Apple Health mobile-SDK-only) are abstracted away. Your app always calls the same endpoints.
- Three endpoints cover the main data types: /api/v1/users/{user_id}/timeseries, /api/v1/users/{user_id}/events/workouts, and /api/v1/users/{user_id}/events/sleep.
- Open Wearables is self-hosted, MIT-licensed, and costs $0 per user.
- You can be running locally in under five minutes with git clone and docker compose up -d.
The Problem Without a Normalization Layer
Imagine you are building a health dashboard that pulls workout and sleep data. You start with Garmin. You write a client, handle webhooks (because Garmin does not support REST polling), map their field names to your internal schema, convert distances from whatever unit Garmin returns to meters, and store it.
Then a user wants to connect their Polar device. Polar uses REST polling via AccessLink. Different auth, different field names, different response shape. You write another client. Then Whoop. OAuth 2.0, defaults to the last 30 days, different sleep stage taxonomy. Another client. If you have looked for a Terra API alternative, this is exactly the problem you are trying to solve.
A rough sketch of what this looks like without any abstraction layer:
# Garmin: webhook handler
def handle_garmin_webhook(payload):
activity = payload["activities"][0]
return {
"distance_m": activity["distanceInMeters"],
"hr_bpm": activity["averageHeartRateInBeatsPerMinute"],
"started_at": parse_garmin_timestamp(activity["startTimeLocal"]),
}
# Polar: polling
def fetch_polar_workouts(user_token, since):
resp = requests.get(
"https://www.polaraccesslink.com/v3/exercises",
headers={"Authorization": f"Bearer {user_token}"},
params={"after": since},
)
exercises = resp.json()["exercises"]
return [
{
"distance_m": e["distance"],
"hr_bpm": e["heart_rate"]["average"],
"started_at": e["start_time"],
}
for e in exercises
]
# Whoop: polling, different schema
def fetch_whoop_workouts(user_token):
resp = requests.get(
"https://api.prod.whoop.com/developer/v1/activity/workout",
headers={"Authorization": f"Bearer {user_token}"},
)
workouts = resp.json()["records"]
return [
{
"distance_m": w["score"]["distance_meter"],
"hr_bpm": w["score"]["average_heart_rate"],
"started_at": w["start"],
}
for w in workouts
]
This is three providers. Each with its own client, its own field mapping, its own timestamp handling. Add Suunto. Add Apple Health (mobile SDK only, no cloud sync). The surface area grows fast and so does the maintenance burden.
With Open Wearables, the sync logic for all of these is handled by the platform. It works as a Terra API alternative that normalizes data across providers, so you do not have to maintain separate clients for each one.
import requests
def get_workouts(user_id: str, api_key: str):
resp = requests.get(
f"http://localhost:8000/api/v1/users/{user_id}/events/workouts",
headers={"X-Open-Wearables-API-Key": api_key},
)
return resp.json()
Same call regardless of whether the data came from Garmin, Polar, Whoop, Suunto, or any other connected provider.
What Open Wearables Does Under the Hood
The normalization is built on five components. Here is what each one does in practice.
Events store discrete sessions: workouts and sleep. Each event has a start and end timestamp, aggregate values (average heart rate, total distance, sleep duration), and metadata linking it to a user, device, and provider. When Garmin sends a webhook and Polar returns the same activity via polling, both end up as Event records with the same shape.
Time Series stores high-frequency float values: continuous heart rate, SpO2, skin temperature. Each record has a timestamp, a value, a type identifier, and a device source. This handles continuous monitoring data from Garmin (24/7) or Suunto (24/7) while also accommodating partial coverage from Whoop or Apple Health.
Descriptors hold static user biometric information: birth date, biological sex, and gender. These do not change with each sync. Stored once and referenced when needed.
ID Mapping links internal references to specific users, devices, and providers. When the same user connects two devices from different providers, ID Mapping keeps those relationships clean without scattering provider-specific identifiers across your tables.
Series Type Definitions provide metadata for entire time series: units, display names, expected ranges. Unit definitions live in one place and apply consistently instead of being scattered across your codebase.
The stack is SQLAlchemy, Pydantic, and Alembic. If you are familiar with FastAPI-style Python applications, the codebase will feel familiar.
Canonical Units: What OW Handles Automatically
Unit normalization is one of the places where provider differences most often cause silent bugs. A distance stored in kilometers looks like a plausible number when you expected meters. A timestamp in local time looks correct until a user travels to a different timezone.
Open Wearables enforces canonical units across all providers: heart rate in bpm, distance in meters, temperature in Celsius, timestamps in UTC. When a provider returns distance in a non-standard unit or a timestamp in local time, OW converts it before storage. Your application always receives data in the same units regardless of where it came from.
This matters especially when aggregating across providers. If one user has Garmin data and another has Polar data and a third has Strava data, the numbers in your database are directly comparable without any additional conversion logic on your side.
One Endpoint Per Data Type, Across All Providers
GET /api/v1/users/{user_id}/timeseries
GET /api/v1/users/{user_id}/events/workouts
GET /api/v1/users/{user_id}/events/sleep
All requests use the X-Open-Wearables-API-Key header. A practical example for sleep data:
import requests
BASE_URL = "http://localhost:8000"
API_KEY = "your-api-key"
def get_sleep_sessions(user_id: str):
response = requests.get(
f"{BASE_URL}/api/v1/users/{user_id}/events/sleep",
headers={"X-Open-Wearables-API-Key": API_KEY},
)
response.raise_for_status()
return response.json()
sessions = get_sleep_sessions("usr_abc123")
for session in sessions:
print(session["started_at"], session["duration_seconds"], session["provider"])
The provider field tells you where the data came from. Everything else is normalized.
Provider Coverage
Not all providers expose the same data types. Current coverage:
- Apple Health: Sleep yes, HRV yes, Continuous yes, Workouts broad. Mobile SDK only (no cloud sync).
- Garmin: Sleep yes, HRV yes, Continuous 24/7, Workouts yes. Webhook-only delivery.
- Polar: Sleep stages, HRV yes, Continuous no, Workouts yes. REST polling via AccessLink.
- Suunto: Sleep yes, HRV yes, Continuous 24/7, Workouts yes. REST with since/limit/offset.
- Whoop: Sleep yes, HRV yes, Continuous partial (significant limitations), Workouts 145+ types.
- Strava: Sleep no, HRV no, Continuous no, Workouts 50+ types.
- Oura: Sleep stages, HRV yes, coming soon.
OW handles the different sync models in its provider integration layer. You do not write webhook handlers for Garmin or polling loops for Polar. You configure the provider connection and OW gets the data into the normalized store.
Quickstart
git clone https://github.com/the-momentum/open-wearables.git
cd open-wearables
docker compose up -d
The API is available at http://localhost:8000. Authentication uses the X-Open-Wearables-API-Key header. No managed service, no per-user pricing, no data leaving your infrastructure. MIT license.
See Related Articles
- How to sync wearable data from multiple devices
- Wearable API integration: comparing SaaS, custom build, and open source
- Garmin Connect API: developer guide
- Building a health app with Whoop API data
- How to use Oura ring data in your app
FAQ
What happens if two providers return conflicting data for the same time window?
Each record includes a provider field, so data from different sources is always distinguishable. OW does not merge or deduplicate across providers automatically. If a user has both Garmin and Apple Health connected and both record the same workout, you will see two Event records with different provider values. How you handle that in your application is up to you.
Does Open Wearables support real-time data, or only batch sync?
It depends on the provider. Garmin delivers data via webhooks as soon as a sync completes, which is close to real-time. Polar and Suunto use REST polling, so latency depends on how frequently you poll. OW handles whatever the provider supports; there is no additional delay introduced by the normalization layer.
Is the data model extensible if I need to store provider-specific fields?
The five-component model covers the most common wearable data types. For fields outside it, the project is open source (MIT), so you can extend the schema using the same SQLAlchemy and Alembic patterns already in use.
How do I handle Apple Health if it requires a mobile SDK?
Apple Health data does not sync to any cloud API. To ingest it into Open Wearables, you need a mobile component that reads from HealthKit and pushes data to your OW instance. Once the data is in OW, it is normalized alongside data from all other providers.