Almost every article comparing HealthKit, Health Connect and aggregator APIs compares the wrong things: which data types each supports, which languages have an SDK, how many devices are covered. Those are the parts you can read off a docs page in ten minutes, and none of them are what makes a wearable integration take four weeks instead of four days.
The things that actually decide your architecture are the ones nobody writes about: whether you can tell that a user denied a permission, whether the OS will really wake your app when data arrives, how far back you're allowed to read, what happens when a phone and a watch both report the same 8,000 steps, and which day a workout that started at 11:50 pm belongs to.
This is the comparison written from those questions. It assumes you're going to spend real money on this and you'd like to know where it goes. If you want the commercial version — scope, price, timeline — that lives on our wearable integrations page.
The three layers, and why "HealthKit vs Terra" is a category error
HealthKit and Health Connect are on-device health stores. They are not wearable APIs. They are local databases on the user's phone that other apps write into, and your app reads out of, with the OS mediating permissions. There is no server. There is no account.
Garmin, Whoop, Fitbit, Oura and Dexcom are vendor cloud APIs. The user has an account with the vendor, the device syncs to the vendor's servers, and you authenticate against the vendor with OAuth and pull or receive data.
Terra, Spike, Rook, Vital and Metriport are aggregators. They sit in front of the vendor cloud APIs (and usually ship a mobile SDK that also reads the on-device stores), normalize everything into one schema, and give you one webhook.
The category error is treating these as three options for the same job. They're three layers of the same system, and a real product usually needs at least two:
- If your users wear an Apple Watch, HealthKit is the only path. There is no Apple Watch cloud API. Terra can't get you Apple Watch data except by shipping an SDK in your app that reads HealthKit — the same thing you'd do directly.
- If your users wear a Whoop, HealthKit gets you a thin, delayed subset at best, because you only see what the Whoop app chose to write into HealthKit. The real data is in Whoop's cloud.
- If you want both, you need an on-device path and a cloud path, and now you need merge rules — which is where the actual work is.
Anyone who tells you a single integration covers everything is selling you something. Usually an aggregator.
Comparison at the level that matters
| Apple HealthKit | Google Health Connect | Aggregator (Terra/Spike/Vital class) | |
|---|---|---|---|
| Where data lives | On device, per-user, no server | On device, per-user, no server | Vendor's cloud, mirrored to the aggregator |
| Auth model | Per-type OS permission prompt | Per-type Android runtime permission | OAuth to each vendor via a hosted widget |
| Can you detect a denied *read*? | No (deliberately) | Yes | Yes, per-connection status |
| Push notification of new data | Observer queries + background delivery | No push — you poll a change token | One webhook for all sources |
| Historical backfill | Whatever is on the device, unlimited | Capped without a special permission | Vendor-dependent, usually a limited window |
| Approval needed to ship | App Review checks health usage | Play Console data-type declaration | The aggregator's onboarding, plus some vendors' own approval |
| Server-side access without the app open | No | No | Yes |
| Who deduplicates | Partly Apple, partly you | User-set app priority, partly you | Almost always you |
That last row is the one that costs money.
Permission models: three genuinely different philosophies
HealthKit hides read denials on purpose
This is the single most surprising thing about HealthKit for engineers coming from any other permission system, and it changes your UX design.
authorizationStatus(for:) tells you the truth about write (share) permission. For read permission, it will report .notDetermined until you ask, and after the user responds it reports the same thing whether they said yes or no. Apple does this deliberately: if an app could detect that a user refused to share their blood glucose or their reproductive health data, the refusal itself would leak the fact that they have data worth hiding.
The practical consequences:
- You can't build a "permissions granted ✅" screen. Any app that shows one is lying, or is only checking write permission.
- Empty results are ambiguous. Zero step samples means either "denied", "granted but no data", or "granted and the device was locked when you queried". You have to disambiguate with heuristics — did any type return data, has the user ever had data, is this a fresh install.
- Your recovery UX has to be permission-agnostic. When a query returns nothing where you expected something, show a non-accusatory prompt that deep-links to Settings → Privacy → Health → [your app]. Do not say "you denied permission." Say "we're not seeing your step data — here's where to check."
- Re-prompting doesn't work. Once a user has responded to a type, iOS never shows the sheet again for it. Your only route back is Settings.
Design for this on day one. Retrofitting it into an app built on the assumption that permissions are observable is a rewrite of your onboarding.
Health Connect tells you the truth, but makes you earn the right to ask
Health Connect uses ordinary Android runtime permissions (android.permission.health.READ_STEPS, READ_HEART_RATE, and so on), declared in the manifest and checked with getGrantedPermissions(). You get a straight answer. That's better.
What it costs you:
- A Play Console declaration. Google requires apps requesting Health Connect data types to complete a declaration form describing your use of each type, and to satisfy its health-data policy before the build goes to production. Plan for this to be a gate on your release, not a formality you handle the week of launch.
- A privacy-policy rationale activity responding to the permissions-rationale intent, so users can see why you want each type from inside Health Connect. Miss it and you fail review.
- Permissions that expire from disuse. Android revokes Health Connect permissions from apps the user hasn't opened for a period. Your integration can be working perfectly and simply stop for a dormant user.
That last one is a genuine production behavior that generates support tickets that look exactly like bugs. Handle it: detect the revocation on next launch, and re-request rather than showing an empty dashboard.
Aggregators move the permission problem, they don't remove it
An aggregator gives you one hosted connection widget and one status per connection. But the OAuth grant still lives at the vendor, the user can revoke it in the vendor's own account settings, and your aggregator will tell you the connection is dead — eventually. And on iOS and Android, an aggregator reading HealthKit or Health Connect runs the same OS permission flow inside its SDK, inside your app. You inherit every constraint above and lose control of the prompt copy, which for health data is a conversion surface worth owning.
Background delivery: what each platform actually guarantees
"Real-time sync" is the phrase in every wearable pitch deck. Here is what it means in practice.
HealthKit: it works, with four caveats you must code around
The mechanism is HKObserverQuery plus enableBackgroundDelivery(for:frequency:withCompletion:), which requires the HealthKit background delivery entitlement alongside the HealthKit capability. When a matching sample is written, iOS wakes your app and calls your observer handler.
The caveats:
- Frequency is capped per data type. You request
.immediate,.hourly,.dailyor.weekly, but iOS only honors.immediatefor a small set of types — the ones Apple considers time-critical, notably blood glucose. Ask for immediate step-count delivery and you'll be quietly downgraded. - You must call the completion handler. If you don't — because you kicked off an async fetch and returned — iOS assumes your app failed to handle the update and progressively backs off delivery. This is the number one cause of "sync worked in testing and died in the field." Call it after bounded work completes.
- A locked device means an inaccessible store. HealthKit data is encrypted while the device is locked, so queries fail rather than returning empty. Distinguish that error from "no data" and retry. An overnight sync that always runs at 3 am on a locked phone never runs.
- Force-quit stops everything until the user launches the app again. No workaround; only reconciliation on next foreground.
Which is the real design lesson: background delivery is an optimization, not a data-integrity mechanism. The integrity mechanism is an HKAnchoredObjectQuery with a persisted anchor that you run on every foreground, which returns everything added and deleted since the anchor. Background delivery makes data arrive sooner. The anchored query makes sure it arrives at all. Build both or build neither.
Health Connect: there is no push, and background reads are a separate permission
Health Connect gives you a differential changes API: request a change token, then ask what changed since that token. There is no callback, no observer, no broadcast. You schedule the poll yourself, which in practice means WorkManager.
Which means you're subject to Android's background execution rules — Doze, App Standby buckets, and per-OEM battery managers considerably more aggressive than stock Android. A periodic WorkManager job on a device with default battery settings can be deferred for hours. Not a bug you can fix; a constraint you design around by reconciling on foreground, exactly as on iOS.
Two more things bite:
- Reading in the background is its own permission. Without it, reads only succeed while your app is in the foreground. A WorkManager job that runs perfectly and reads nothing is the symptom.
- Change tokens expire. A stale token forces a full read, which collides with the backfill limits below. Persist it, handle the expiry path explicitly, and have a full-resync routine you've actually tested.
Cloud APIs and aggregators: webhooks you have to treat as unreliable
Vendor cloud APIs (and the aggregators in front of them) push webhooks. Webhooks are better than polling and worse than they look. Every serious implementation needs idempotency (the same event will be delivered twice — key on the vendor's record ID plus a version, never on arrival order), out-of-order handling (a sleep record can arrive after a workout that happened later; never build state machines that assume ordering), a replay path ("fetch everything since timestamp T," triggerable for one user or all of them, because your endpoint will be down at some point), and a dead-letter queue, because one vendor's malformed payload should not stall the queue for everyone.
An aggregator gives you one webhook shape instead of six. That's a genuine saving — maybe a week — and it removes none of those four requirements.
Data freshness: the chain is only as fast as its slowest hop
Nobody's data is real-time. What you have is a chain — sensor → device buffer → phone (BLE sync) → vendor cloud → aggregator → your webhook → your database → your UI — and the hops that dominate are the ones you don't control:
- Ring and strap to phone. An Oura ring or a Whoop strap syncs when its companion app runs a BLE sync. If the user hasn't opened that app since waking, last night's sleep isn't anywhere yet. This is the single biggest source of "why is my dashboard empty at 8 am."
- Vendor processing. Derived scores — recovery, readiness, strain, body battery — are computed after the raw data lands, not with it. Raw sleep is often available before the sleep score.
- Apple Watch to iPhone. Usually quick, but a watch that's been out of range accumulates and dumps in a burst, which is when your dedup logic gets stress-tested.
Give users a "last synced" timestamp per source, not a global one, and make it the vendor's timestamp rather than yours. It converts a support ticket into a self-service action ("open the Whoop app").
Historical backfill: the scoping surprise that eats a sprint
Users expect their history. "I've had this ring for two years, where are my two years?" This is where the platforms diverge most sharply, and where estimates go wrong most often.
HealthKit. Everything on the device is yours to read from Date.distantPast, no special permission beyond the type. That sounds generous until you run it: a heavy user has hundreds of thousands of heart-rate samples, and an unbounded query will spike memory and can get your app killed. Page it with sorted, limited queries, or better, use HKStatisticsCollectionQuery for daily aggregates and pull raw samples only for windows you actually render. Note too that a user on a brand-new phone has whatever iCloud restored, which is often less than they think.
Health Connect. By default, an app can only read data that was written before it was granted permission up to a limited historical window. Reading further back requires the additional health-data-history permission, which is itself subject to Google's declaration process. If your product's value proposition is "see your last two years," you have to plan for this and you may not get it.
Vendor cloud APIs. Most offer paginated historical endpoints or an explicit backfill request. Garmin's is asynchronous: you request a range and they push it to your webhook over time rather than returning it. Others cap how far back, or how much per call. Assume backfill is rate-limited and can take hours per user, and design an onboarding that says "importing your history — we'll notify you" instead of a spinner.
Aggregators fetch history on connect over a bounded window, often as a priced feature. Ask how far back, how long, and whether it counts against your plan differently from ongoing sync.
The honest scoping advice: decide the minimum history your product genuinely needs before you price the integration. "30 days" and "everything since the beginning of time" are different projects. Most products need 30–90 days for the experience and can import the rest lazily for the users who ask.
Deduplication: the part that makes users distrust your numbers
A user with an iPhone and an Apple Watch generates step samples from both. Add Strava, which writes workouts to HealthKit. Add a Garmin, which writes to both HealthKit and its own cloud that you're also reading. Sum naively and you show a user 23,000 steps on a day they walked 8,000, and they screenshot it.
HealthKit applies its own logic when you use statistics queries for certain cumulative types from Apple's own sources — this is why Apple's Health app shows a sane step count for an iPhone-plus-Watch user. It does not solve the general problem: third-party writers, overlapping workouts, and conflicting energy figures are yours. Use HKStatisticsOptions.separateBySource when you need to see the components, and read HKSourceRevision and the device metadata on every sample so you can build priority rules at all.
Health Connect has a genuinely nice feature almost nobody uses: the user sets an app priority list per data type inside Health Connect, and the aggregation API respects it when records overlap. Surface this — when a user complains about double-counted steps on Android, the fix is often two taps in Health Connect, not a code change. Also use clientRecordId and clientRecordVersion if you write data, so your updates upsert instead of duplicating.
Aggregators generally do not deduplicate across connected sources. They hand you a Whoop workout and a Garmin workout for the same run and it's your problem. Verify any claim otherwise with a real two-device test account.
The merge algorithm that works
For cumulative quantities (steps, distance, active energy):
- Bucket samples into the day in the user's local calendar (see timezone section — this is the hard part).
- Within a bucket, group by source.
- Apply a source priority: a dedicated wearable outranks the phone; a chest strap outranks a wrist optical sensor for heart rate; an explicitly user-chosen "primary device" outranks everything.
- Walk the samples as intervals. Take the highest-priority source's value for any interval it covers; fill gaps from the next source down. Do not sum across sources and do not take the max — both are wrong in ways users notice.
For sessions (workouts, sleep):
- Two sessions from different sources whose intervals overlap by more than a threshold are the same session. Around 50–70% of the shorter session's duration works; tune it against real data.
- Merge field-by-field rather than picking a winner wholesale: duration from the higher-sampling-rate source, heart rate from the strap, GPS from the device that has it, calories from whichever source you've told the user you trust — then tell the user which source you used.
- Keep the losers, stored and flagged as suppressed, so that when a user asks "why is my Garmin run missing" you can answer instead of guessing. This is the one teams add after their first support crisis. Build it first.
Unit normalization: pick a canonical set, convert at the boundary
This looks trivial and isn't, because the traps are semantic, not arithmetic.
- Energy. HealthKit separates active from basal energy; several vendors report one "calories" figure that may or may not include basal, and Whoop reports kilojoules. Summing a vendor's total against HealthKit's active energy overstates burn every time.
- Heart rate variability. This is the big one. HealthKit's type is literally
heartRateVariabilitySDNN— standard deviation of NN intervals, measured over specific windows. Most wearable vendors report an RMSSD-derived overnight figure. They are different statistics. Plotting them on one chart produces a step change when a user switches devices, and a support ticket claiming your app broke their HRV. Either keep them in separate series, or normalize to z-scores within a source and plot the deviation, and say so in the UI. - Sleep stages. HealthKit uses in-bed / core / deep / REM / awake. Health Connect uses light / deep / REM / awake / out-of-bed. Vendors use their own. There is no clean mapping and "core" is not identical to "light". Define your internal taxonomy, write an explicit per-source mapping table, and keep the raw vendor label alongside the mapped one.
- Glucose. mg/dL in the US, mmol/L nearly everywhere else. Store one, display by locale, never let a raw number reach the UI without a formatter. See CGM app integration for the rest of that world.
The rule: one canonical internal unit per measure, converted at ingest, with the source's original unit retained. SI internally, locale formatting at the view layer. An afternoon at the start, a data migration avoided later.
Timezones: the bug that breaks streaks
The classic failure: a user flies from New York to London. A workout starts at 11:50 pm New York time. Your server stores UTC. Your streak logic buckets by UTC date. The workout lands on tomorrow, today shows zero, and a 200-day streak dies.
The model that survives:
- Store the instant (UTC) — always, for ordering and for interval math.
- Store the UTC offset at the moment of the event, taken from the source. Health Connect gives you
startZoneOffsetandendZoneOffseton records, which is genuinely better than what HealthKit gives you; HealthKit exposes a timezone in sample metadata for some types and not others, so you often have to record the device's zone yourself at ingest. - Store the local calendar date as a derived, materialized column. Compute it once, at ingest, from the instant plus the offset. Never compute "which day is this" at query time using the user's current timezone.
Then: daily aggregates and streaks use the stored local date, because a workout belongs to the day it felt like to the person doing it. Never compute day boundaries by adding 86,400 seconds — there is a 23-hour day and a 25-hour day every year, and sleep sessions cross both, so use calendar arithmetic in the relevant zone. Sleep belongs to the wake day by convention (11 pm Monday to 7 am Tuesday is Tuesday's sleep); vendors differ slightly, so normalize to one rule and document it. And backfilled history carries historical offsets — a record from a trip two years ago should not be bucketed with the user's current timezone. If your source doesn't give you one, record that fact in the data rather than guessing.
Aggregator vs direct: the honest decision
Aggregators are good products and frequently the wrong purchase, for predictable reasons.
Use an aggregator when you need four or more cloud vendors and you need them soon — this is the strongest case by far, because what you're avoiding isn't code, it's six OAuth flows, six webhook contracts and six sets of vendor paperwork. Also when your data needs are shallow and normalized (daily summaries, sleep, workouts, recovery scores), which is exactly what aggregator schemas are excellent at, or when you're pre-product-market-fit and the wearable is a feature rather than the product.
Go direct when one or two platforms cover most of your users — if 80% of your base is on Apple Watch, an aggregator is a per-user monthly fee to wrap an SDK you'd ship anyway. Go direct when you need depth: full activity files, per-second samples, vendor-specific proprietary metrics. Aggregators normalize by dropping what doesn't fit their schema, and what got dropped is often the thing your product does. Go direct when cost per connected user matters at your scale — per-monthly-connected-user pricing is cheap at 500 users and a real line item at 50,000, so model it at your projected scale. And go direct when you have a BAA requirement: if wearable data lands in a system that also holds identifiers, your aggregator is a business associate and needs to sign. Ask before you build. Our HIPAA-compliant app development page covers where that boundary sits.
Things an aggregator cannot do: get you access a vendor won't grant (some vendors still require you to hold your own developer agreement even behind an aggregator — ask which of yours do); replace the on-device SDK; deduplicate across sources; or make a vendor's data fresher than the vendor makes it.
The pattern we most often recommend: direct on the on-device platforms, aggregator for the long tail of cloud vendors. HealthKit and Health Connect are where most of your users are and where UX matters most, so own them. Whoop, Oura, Garmin, Polar, Suunto and the rest are a support burden that scales linearly with vendor count, so rent them — until one becomes strategically important enough to bring in-house. The vendor-by-vendor detail is in Whoop, Oura, Garmin and Fitbit access tiers.
What we'd actually build, in order
- One platform, end to end, properly. Whichever your users are actually on. Permissions, background delivery, anchored reconciliation, dedup, units, timezones, a visible last-synced state, and QA on physical hardware — not sandbox data.
- The source-of-truth model. A normalized internal schema with source attribution on every record, before the second integration exists. Adding this after three integrations is a migration.
- The second platform. This is where merge rules get real. Budget for it to be harder than the first even though the API is easier.
- Everything else via aggregator, if the numbers say so.
Two to four weeks per platform is the honest timeline, and the API wiring is the fast part. We publish that as a fixed price — $3,500 per platform — with the reasoning in wearable integration cost and timeline, and every other package on the pricing page. If you're not sure which platform to start with, an MVP Planning Sprint settles it with your analytics rather than a guess.
And the unprofitable advice, which we give often: if your users are 90% iPhone and all you need is steps, sleep and workouts, you need one HealthKit integration and nothing else. Not an aggregator subscription, not six vendor connections, not a watch app. Build the one, ship it, and let your support inbox decide the second.
Frequently asked questions
Is Terra API better than building HealthKit directly?
They solve different problems. Terra's value is the cloud vendors — one contract and one webhook instead of six. For HealthKit specifically, Terra ships an SDK that reads the same on-device store you'd read yourself, so you're paying a recurring fee to wrap an integration you still have to configure, prompt for and debug inside your own app. Direct for on-device, aggregator for the cloud long tail is the split that usually wins.
Does Health Connect replace Google Fit?
Health Connect is Google's on-device successor to the Google Fit platform APIs, and Google has been sunsetting the legacy Fit APIs. If you have an existing Fit integration, you're migrating, not choosing. Plan the migration as its own piece of work — the data models differ, and historical data does not transfer automatically.
Can I read Apple Watch data from my server without an app?
No. There is no Apple Watch cloud API. All Apple Watch data reaches you through HealthKit on a paired iPhone, which means code running in your iOS app. Any vendor claiming server-side Apple Watch access is either describing an SDK inside your app or describing something else.
Why do my step counts double for some users?
Almost always because a phone and a wearable both report steps and you're summing sources. Fix it with source priority and interval-based merging rather than summation, and on Android check whether the user's Health Connect app priority list is set the way they expect. It's the most common wearable bug we're asked to repair.
How much history can I import when a user connects?
It depends entirely on the source. HealthKit gives you everything on the device. Health Connect limits historical reads without an additional permission. Cloud vendors vary from a few months to several years, usually rate-limited and often asynchronous. Decide the history your product actually needs before you scope — it's the difference between a two-week and a four-week integration.
Do we need a BAA with a wearable data provider?
If the wearable data is combined with identifiers in a system covered by HIPAA, then yes, your processors are business associates. Not every aggregator will sign one, and that's a question to settle before you write code. Zee Palm builds HIPAA-aware systems and maps these boundaries during planning — we don't issue certifications or legal sign-off. If you want to talk through your specific setup, get in touch.

