Capacitor plugin to query data from Apple Health and Google Health Connect
Some parts, concepts and ideas are borrowed from cordova-plugin-health. Big thanks to @dariosalvi78 for the support.
npm install capacitor-health
npx cap sync- Make sure your app id has the 'HealthKit' entitlement when this plugin is installed (see iOS dev center).
- Also, make sure your app and App Store description comply with the Apple review guidelines.
- There are two keys to be added to the info.plist file: NSHealthShareUsageDescription and NSHealthUpdateUsageDescription.
- Android Manifest in application tag
<!-- For supported versions through Android 13, create an activity to show the rationale
of Health Connect permissions once users click the privacy policy link. -->
<activity
android:name="com.fit_up.health.capacitor.PermissionsRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- For versions starting Android 14, create an activity alias to show the rationale
of Health Connect permissions once users click the privacy policy link. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity="com.fit_up.health.capacitor.PermissionsRationaleActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>- Android Manifest in root tag
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
<uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" />
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_EXERCISE_ROUTE" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
<uses-permission android:name="android.permission.health.READ_BODY_FAT" />
<uses-permission android:name="android.permission.health.READ_LEAN_BODY_MASS" />
<uses-permission android:name="android.permission.health.READ_NUTRITION" />Only declare the permissions your app actually requests - Health Connect shows every declared permission on the consent screen.
flights-climbed is available through both queryAggregated and queryRecords. Apple Health
calls these flights climbed and Health Connect calls them floors climbed, but both define one
as roughly three meters of elevation gain, so the plugin exposes a single type with one
permission and one unit.
dataType |
Permission | Unit | Apple Health | Health Connect |
|---|---|---|---|---|
flights-climbed |
READ_FLIGHTS_CLIMBED |
count | flightsClimbed |
FloorsClimbedRecord |
await Health.requestHealthPermissions({ permissions: ['READ_FLIGHTS_CLIMBED'] });
// Aggregated - one total per bucket
const { aggregatedData } = await Health.queryAggregated({
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-01-08T00:00:00.000Z',
dataType: 'flights-climbed',
bucket: 'day',
});
// [{ startDate: '...', endDate: '...', value: 14 }]
// Raw - every record, with the app that wrote it
const { records } = await Health.queryRecords({
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-01-08T00:00:00.000Z',
dataType: 'flights-climbed',
});
// [{ startDate: '...', endDate: '...', value: 3, sourceBundleId: '...', sourceName: '...', manual: false }]Notes:
- Health Connect can report fractional flights, Apple Health always reports whole ones.
- Unlike body composition, these records cover a time span, so
startDateandendDatediffer. - The Android permission is
android.permission.health.READ_FLOORS_CLIMBED- Health Connect names it after floors, while the plugin's cross-platform permission isREAD_FLIGHTS_CLIMBED. queryAggregatedonly supportsbucket: 'day'on Android;'hour'and'week'work on iOS only. That limit applies to every aggregated data type, not just this one.
queryRecords reads body composition as individual measurements. The four supported types are
available on both platforms and behave identically - same permission name, same unit, same value
range - so callers do not need to branch on the platform.
dataType |
Permission | Unit | Apple Health | Health Connect |
|---|---|---|---|---|
weight |
READ_WEIGHT |
kilograms | bodyMass |
WeightRecord |
height |
READ_HEIGHT |
meters | height |
HeightRecord |
body-fat |
READ_BODY_FAT |
percent (0 - 100) | bodyFatPercentage |
BodyFatRecord |
lean-body-mass |
READ_LEAN_BODY_MASS |
kilograms | leanBodyMass |
LeanBodyMassRecord |
await Health.requestHealthPermissions({ permissions: ['READ_WEIGHT', 'READ_BODY_FAT'] });
const { records } = await Health.queryRecords({
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-02-01T00:00:00.000Z',
dataType: 'weight',
});
// [{ startDate: '...', endDate: '...', value: 81.4, sourceBundleId: '...', sourceName: '...', manual: false }]Notes:
- These are point-in-time measurements, so
startDateandendDateof each record are equal. - Apple Health stores body fat as a fraction (0 - 1); the plugin scales it to 0 - 100 to match Health Connect.
queryAggregateddoes not support these types on either platform. They are discrete measurements, and summing them is meaningless - four of the underlying Health Connect records do not define an aggregate metric at all.- Bone mass and body water exist only in Health Connect; BMI and waist circumference exist only in Apple Health. None of them are exposed, to keep the API platform-independent.
- iOS dates must include fractional seconds (
2026-01-01T00:00:00.000Z).
queryNutrition reads logged food and meals, with energy and macronutrients. It needs the
READ_NUTRITION permission.
await Health.requestHealthPermissions({ permissions: ['READ_NUTRITION'] });
const { entries } = await Health.queryNutrition({
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-01-02T00:00:00.000Z',
limit: 100, // optional
includeUngroupedSamples: true, // optional, iOS only
});
// [{
// id: '...', startDate: '...', endDate: '...',
// name: 'Porridge', mealType: 'breakfast',
// energyKcal: 310,
// macros: { proteinG: 11, carbohydratesG: 54, fatG: 6 },
// sourceBundleId: 'com.example.food', sourceName: 'Food App',
// grouping: 'record',
// }]The result shape is the same on both platforms:
- Energy is always kilocalories and macros are always grams, whatever unit the writing app used.
- A nutrient the writing app did not record is left out, never reported as
0. A recorded0is kept. - Entries are sorted by
startDateascending.limitreturns the earliestlimitentries.
The platforms store food differently, though, so some fields depend on the platform:
| Android (Health Connect) | iOS (HealthKit) | |
|---|---|---|
| One entry is | one NutritionRecord |
one food correlation (HKCorrelationType(.food)), plus loose samples with includeUngroupedSamples |
grouping |
'record' |
'correlation', or 'sample' for loose samples |
| Nutrient values | read from the record | summed over the correlation's member samples |
name |
NutritionRecord.name |
HKMetadataKeyFoodType metadata |
mealType |
always set; MEAL_TYPE_UNKNOWN becomes 'unknown' |
usually undefined - HealthKit has no standard meal type (see below) |
sourceName |
omitted - Health Connect records carry no app name | name of the writing app |
includeUngroupedSamples |
ignored | see below |
| Permission | android.permission.health.READ_NUTRITION |
read access to dietary energy, protein, carbohydrates, total fat, saturated fat, sugar and fiber |
Notes:
- iOS loose samples. Many apps write single nutrient samples (for example just the energy)
without wrapping them in a food correlation. These are not returned by default. With
includeUngroupedSamples: true, each sample that is not a member of a returned correlation is added as its own entry withgrouping: 'sample'and only that one nutrient set. Samples are never merged into meals heuristically, so one meal can show up as severalsampleentries. - iOS meal type. HealthKit defines no meal type. The plugin only sets
mealTypewhen the correlation's (or sample's) metadata has a key namedMeal,MealType,meal_typeorHKFoodMeal(case-insensitive) with the valuebreakfast,lunch,dinner,snackorsnacks(case-insensitive). Anything else leaves it undefined. The meal type is never guessed from the time of day. - Android history. Like every other query in this plugin,
queryNutritiondoes not requestREAD_HEALTH_DATA_HISTORY, so Health Connect only returns data from 30 days before the permission was first granted. - iOS dates must include fractional seconds (
2026-01-01T00:00:00.000Z).
isHealthAvailable()checkHealthPermissions(...)requestHealthPermissions(...)openAppleHealthSettings()openHealthConnectSettings()showHealthConnectInPlayStore()queryAggregated(...)queryWorkouts(...)queryRecords(...)queryNutrition(...)- Interfaces
- Type Aliases
isHealthAvailable() => Promise<{ available: boolean; }>Checks if health API is available. Android: If false is returned, the Google Health Connect app is probably not installed. See showHealthConnectInPlayStore()
Returns: Promise<{ available: boolean; }>
checkHealthPermissions(permissions: PermissionsRequest) => Promise<PermissionResponse>Android only: Returns for each given permission, if it was granted by the underlying health API
| Param | Type | Description |
|---|---|---|
permissions |
PermissionsRequest |
permissions to query |
Returns: Promise<PermissionResponse>
requestHealthPermissions(permissions: PermissionsRequest) => Promise<PermissionResponse>Requests the permissions from the user.
Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in the Health Connect app. See openHealthConnectSettings()
iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all permissions were granted.
| Param | Type | Description |
|---|---|---|
permissions |
PermissionsRequest |
permissions to request |
Returns: Promise<PermissionResponse>
openAppleHealthSettings() => Promise<void>Opens the apps settings, which is kind of wrong, because health permissions are configured under: Settings > Apps > (Apple) Health > Access and Devices > [app-name] But we can't go there directly.
openHealthConnectSettings() => Promise<void>Opens the Google Health Connect app
showHealthConnectInPlayStore() => Promise<void>Opens the Google Health Connect app in PlayStore
queryAggregated(request: QueryAggregatedRequest) => Promise<QueryAggregatedResponse>Query aggregated data
flights-climbed behaves identically on Android and iOS - one flight is
roughly three meters of elevation gain on both platforms.
| Param | Type |
|---|---|
request |
QueryAggregatedRequest |
Returns: Promise<QueryAggregatedResponse>
queryWorkouts(request: QueryWorkoutRequest) => Promise<QueryWorkoutResponse>Query workouts
| Param | Type |
|---|---|
request |
QueryWorkoutRequest |
Returns: Promise<QueryWorkoutResponse>
queryRecords(request: QueryRecordsRequest) => Promise<QueryRecordsResponse>Query individual records for a given data type. Unlike queryAggregated, this returns each record separately with its data origin, which is useful for detecting duplicate sources.
Supports steps, flights-climbed and the body composition types
weight, height, body-fat and lean-body-mass. All of them behave
identically on Android and iOS - see {@link RecordDataType} for the units.
Body composition measurements are taken at a single point in time, so
startDate and endDate of the returned records are equal.
| Param | Type |
|---|---|
request |
QueryRecordsRequest |
Returns: Promise<QueryRecordsResponse>
queryNutrition(request: QueryNutritionRequest) => Promise<QueryNutritionResponse>Query logged food and meals, with energy and macronutrients.
Requires the READ_NUTRITION permission. Entries are sorted by
startDate ascending. Energy is always in kilocalories and macros in
grams. A nutrient the source did not record is omitted, never reported
as 0.
The platforms store food differently, which shows up in the entry's
grouping:
- Android (Health Connect): every
NutritionRecordis one entry withgrouping: 'record'. Health Connect stores the meal type natively. - iOS (HealthKit): every food correlation (
HKCorrelationType(.food)) is one entry withgrouping: 'correlation'; its nutrients are summed from the correlation's member samples. HealthKit has no standard meal type, somealTypeis usually undefined on iOS. - iOS with
includeUngroupedSamples: true: nutrient samples that are not part of any returned food correlation are added as one entry each withgrouping: 'sample'and only that one nutrient set. Many apps write loose samples instead of correlations, so without this option their data is not returned on iOS.
| Param | Type |
|---|---|
request |
QueryNutritionRequest |
Returns: Promise<QueryNutritionResponse>
| Prop | Type |
|---|---|
permissions |
{ [key: string]: boolean; }[] |
| Prop | Type |
|---|---|
permissions |
HealthPermission[] |
| Prop | Type |
|---|---|
aggregatedData |
AggregatedSample[] |
| Prop | Type |
|---|---|
startDate |
string |
endDate |
string |
value |
number |
| Prop | Type | Description |
|---|---|---|
startDate |
string |
|
endDate |
string |
|
dataType |
'steps' | 'active-calories' | 'flights-climbed' | 'mindfulness' |
|
bucket |
string |
|
dataOrigins |
string[] |
Optional list of package names (Android) or bundle identifiers (iOS) to restrict the aggregation to. When omitted or empty, data from all sources is included. Example: ['com.sec.android.app.shealth'] to only aggregate Samsung Health data. |
| Prop | Type |
|---|---|
workouts |
Workout[] |
| Prop | Type |
|---|---|
startDate |
string |
endDate |
string |
workoutType |
string |
sourceName |
string |
id |
string |
duration |
number |
distance |
number |
steps |
number |
calories |
number |
sourceBundleId |
string |
route |
RouteSample[] |
heartRate |
HeartRateSample[] |
| Prop | Type |
|---|---|
timestamp |
string |
lat |
number |
lng |
number |
alt |
number |
| Prop | Type |
|---|---|
timestamp |
string |
bpm |
number |
| Prop | Type |
|---|---|
startDate |
string |
endDate |
string |
includeHeartRate |
boolean |
includeRoute |
boolean |
includeSteps |
boolean |
| Prop | Type |
|---|---|
records |
HealthRecord[] |
| Prop | Type |
|---|---|
startDate |
string |
endDate |
string |
value |
number |
sourceBundleId |
string |
sourceName |
string |
manual |
boolean |
| Prop | Type |
|---|---|
startDate |
string |
endDate |
string |
dataType |
RecordDataType |
| Prop | Type |
|---|---|
entries |
NutritionEntry[] |
| Prop | Type | Description |
|---|---|---|
id |
string |
Health Connect record id (Android) or HealthKit object UUID (iOS). |
startDate |
string |
|
endDate |
string |
|
name |
string |
Food or meal name, if the source stored one. |
mealType |
MealType |
|
energyKcal |
number |
Energy in kilocalories. Omitted if the source did not record it. |
macros |
NutritionMacros |
|
sourceBundleId |
string |
Package name (Android) or bundle identifier (iOS) of the writing app. |
sourceName |
string |
Name of the writing app. iOS only - Health Connect records carry no app name, so this is omitted on Android. |
grouping |
NutritionGrouping |
Macronutrients of a nutrition entry, in grams. A nutrient the source did
not record is omitted, never 0.
| Prop | Type |
|---|---|
proteinG |
number |
carbohydratesG |
number |
fatG |
number |
saturatedFatG |
number |
sugarG |
number |
fiberG |
number |
| Prop | Type | Description |
|---|---|---|
startDate |
string |
|
endDate |
string |
|
limit |
number |
Maximum number of entries to return: the earliest limit entries after sorting. When omitted, all entries in the range are returned. |
includeUngroupedSamples |
boolean |
iOS only; ignored on Android, where every entry is a record. When true, nutrient samples that belong to no returned food correlation are returned as individual entries with grouping: 'sample'. Defaults to false. |
'READ_STEPS' | 'READ_WORKOUTS' | 'WRITE_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_FLIGHTS_CLIMBED' | 'READ_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_BODY_FAT' | 'READ_LEAN_BODY_MASS' | 'READ_NUTRITION'
Data types that can be read as individual records via queryRecords.
All of these behave identically on Android and iOS: same units, same value ranges, same result shape.
| dataType | Permission | Unit |
|---|---|---|
steps |
READ_STEPS |
count |
flights-climbed |
READ_FLIGHTS_CLIMBED |
count |
weight |
READ_WEIGHT |
kilograms |
height |
READ_HEIGHT |
meters |
body-fat |
READ_BODY_FAT |
percent (0 - 100) |
lean-body-mass |
READ_LEAN_BODY_MASS |
kilograms |
flights-climbed counts flights of stairs. Both platforms define one
flight as roughly three meters of elevation gain (Apple flightsClimbed,
Health Connect FloorsClimbedRecord). Health Connect can report fractional
flights, Apple Health always reports whole ones.
'steps' | 'flights-climbed' | 'weight' | 'height' | 'body-fat' | 'lean-body-mass'
Meal a nutrition entry belongs to.
Android maps Health Connect's MealType constants; MEAL_TYPE_UNKNOWN and
unrecognised values become 'unknown'. iOS has no standard meal type
metadata, so it only sets this when the writing app stored a recognisable
meal hint (see README).
'breakfast' | 'lunch' | 'dinner' | 'snack' | 'unknown'
How a nutrition entry was assembled from the platform's data.
record: one Health ConnectNutritionRecord(Android).correlation: one HealthKit food correlation, nutrients summed over its member samples (iOS).sample: one loose HealthKit nutrient sample that belongs to no returned correlation (iOS, only withincludeUngroupedSamples).
'record' | 'correlation' | 'sample'