npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-native-nitro-health

v0.4.0

Published

react-native-nitro-health is a react native package built with Nitro

Downloads

83

Readme

react-native-nitro-health

Cross-platform HealthKit and Health Connect access for React Native, built with Nitro Modules.

Version Downloads License

Requirements

  • React Native 0.76 or newer
  • Node.js 18 or newer
  • iOS 16 or newer
  • Android 9 (API 28) or newer with Health Connect available

Installation

bun add react-native-nitro-health react-native-nitro-modules

Rebuild the native application after installation. Import the runtime and all public types from the package root:

import { NitroHealth, type HealthPermission, type StepSample } from 'react-native-nitro-health'

Public package surface

The package root exports NitroHealth and consumer-facing types only. Native transport types named Native*, the generated NitroHealthSpec, generated Nitro files, and internal mapping helpers are not public exports.

Package entry points are intentionally restricted to:

  • react-native-nitro-health
  • react-native-nitro-health/jest/mock
  • react-native-nitro-health/jest/setup
  • react-native-nitro-health/package.json

There are no iOS, Android, src, spec, or other platform subpath imports. Consumer workflows use capabilities and tagged results instead of importing a platform implementation.

Availability And Recovery

getAvailability() is synchronous and returns a discriminated value. A recovery action exists only when the current unavailable state can be addressed by installing or updating the Android Health Connect provider.

import { NitroHealth } from 'react-native-nitro-health'

const availability = NitroHealth.getAvailability()

if (availability.status === 'unavailable') {
  if (availability.reason === 'provider-install-or-update-required') {
    const recovery = await NitroHealth.performAvailabilityRecovery(availability.recovery)

    if (recovery.status === 'user-action-required') {
      // The provider store opened. The user still needs to install or update it.
    }
  } else {
    // reason is 'not-supported' or 'service-unavailable'.
  }
}

The availability shapes are:

type HealthAvailability =
  | { status: 'available' }
  | {
      status: 'unavailable'
      reason: 'not-supported' | 'service-unavailable'
    }
  | {
      status: 'unavailable'
      reason: 'provider-install-or-update-required'
      recovery: { kind: 'install-or-update-provider' }
    }

performAvailabilityRecovery() returns either { status: 'user-action-required', destination: 'provider-store' } or { status: 'unavailable', reason: 'no-recovery-action' | 'destination-unavailable' }. Opening the store is not proof that the provider was installed; call getAvailability() again when the app resumes.

On iOS, unsupported devices return not-supported and there is no install recovery action.

Capabilities

Use getCapabilities() for workflows whose implementation differs by platform. Do not branch on Platform.OS.

const capabilities = await NitroHealth.getCapabilities()

if (capabilities.status === 'unavailable') {
  console.log(capabilities.availability.reason)
} else if (capabilities.backgroundChanges.mode === 'observer') {
  // The system can wake the app and emit change hints.
  console.log(capabilities.backgroundChanges.frequencies)
  console.log(capabilities.historyRead)
} else {
  // mode is 'polling' and scheduling is always 'app-owned'.
  console.log(capabilities.backgroundChanges.backgroundRead)
  console.log(capabilities.historyRead)
}

Background changes are one of:

  • observer: system observer hints, frequencies immediate, hourly, daily, and weekly, with normal read authorization covering background reads. iOS reports this capability whenever HealthKit is available; configuration rejects if the host lacks the required background-delivery entitlement.
  • polling: the consumer app owns scheduling and separately checks background-read access.

historyRead and polling backgroundRead use these states:

  • included: included in normal read authorization.
  • unsupported: the health service cannot provide the access.
  • not-declared: the Android manifest permission is missing.
  • not-granted: the permission is declared but has not been granted.
  • granted: the additional Android permission is granted.

Request optional access only when its capability is not-granted:

const capabilities = await NitroHealth.getCapabilities()

if (
  capabilities.status === 'available' &&
  capabilities.backgroundChanges.mode === 'polling' &&
  capabilities.backgroundChanges.backgroundRead === 'not-granted'
) {
  const result = await NitroHealth.requestAdditionalAccess('background-read')
  console.log(result.access, result.status)
}

if (capabilities.status === 'available' && capabilities.historyRead === 'not-granted') {
  const result = await NitroHealth.requestAdditionalAccess('history-read')
  console.log(result.access, result.status)
}

requestAdditionalAccess() returns { access, status } after the request, or an unavailable result carrying health availability. Android opens permission UI only from not-granted; not-declared, unsupported, and already granted states return without prompting. Both access types return included on iOS.

Permissions

Supported data types are steps, heartRate, bloodPressure, bloodGlucose, bodyTemperature, respiratoryRate, bodyFat, leanBodyMass, basalBodyTemperature, restingHeartRate, heartRateVariability, distance, activeEnergyBurned, hydration, floorsClimbed, oxygenSaturation, height, vo2Max, sleep, bodyMass, workout, and nutrition. Read permissions additionally accept the aggregate-only energy types basalEnergyBurned and totalEnergyBurned (see Basal and Total Energy); write permissions for them are rejected in JS.

Authorization

Authorization results contain one status entry per requested permission, preserving input order. There is no aggregate granted, denied, partial, or prompt-status result.

import { NitroHealth, type HealthPermission } from 'react-native-nitro-health'

const permissions: HealthPermission[] = [
  { accessType: 'read', dataType: 'steps' },
  { accessType: 'read', dataType: 'sleep' },
  { accessType: 'write', dataType: 'workout' },
]

const before = await NitroHealth.getPermissionStatuses(permissions)

if (before.status === 'unavailable') {
  console.log(before.availability.reason)
} else {
  for (const entry of before.statuses) {
    console.log(entry.permission.accessType, entry.permission.dataType, entry.status)
  }
}

const authorization = await NitroHealth.requestAuthorization(permissions)

if (authorization.status === 'completed') {
  for (const entry of authorization.statuses) {
    if (entry.status === 'granted') {
      continue
    }

    // Handle this specific permission's notGranted, notDetermined, or
    // unverifiable state in app UI.
    console.log(entry.permission, entry.status)
  }
}

Per-entry statuses are granted, notGranted, notDetermined, and unverifiable.

  • Android reports granted or notGranted when Health Connect is available. Health Connect does not distinguish a denial from access that was never requested.
  • HealthKit never discloses read authorization, so every iOS read entry is unverifiable, before and after authorization. iOS write entries can be granted, notGranted, or notDetermined.
  • When health data is unavailable, getPermissionStatuses() and requestAuthorization() return status: 'unavailable', include the typed availability, and mark every requested entry unverifiable.

On Android, a missing read permission causes reads to reject. On iOS, reads reject until authorization has been requested at least once; after the user responds, a denied HealthKit read resolves with empty results because denial is indistinguishable from no data. Missing write permission is detectable and rejects on both platforms.

Manage And Revoke

Permission-management methods report whether the operation completed or still requires the user. Their result shape provides the workflow; no platform check is needed.

const management = await NitroHealth.managePermissions()

if (management.status === 'user-action-required') {
  if (management.action.kind === 'opened') {
    // Android Health Connect settings opened.
  } else {
    // Explain how to open Health app > Sharing > Apps on iOS.
  }
}

const revocation = await NitroHealth.revokeAllPermissions()

if (revocation.status === 'completed') {
  // Android revoked the app's Health Connect permissions directly.
} else if (revocation.status === 'user-action-required') {
  // iOS requires manual revocation in the Health app.
}

Every user-action-required result carries a typed action:

type HealthPermissionAction =
  | { kind: 'opened'; destination: 'health-connect-settings' }
  | { kind: 'manual'; destination: 'health-app-permissions' }

Both methods can return { status: 'unavailable', availability }. managePermissions() opens Health Connect settings on Android and returns a stable manual Health-app destination on iOS. revokeAllPermissions() completes directly on Android; HealthKit does not provide direct all-permission revocation, so iOS returns the manual action — its user-action-required result never carries kind: 'opened'. Use the destination literal to select localized instructions rather than checking the platform.

Re-read permission statuses and perform a full data resync after a material permission change. Cached data and change tokens do not prove current authorization.

Consumer Configuration

On iOS, enable the HealthKit capability and add usage descriptions to the consumer app's Info.plist:

<key>NSHealthShareUsageDescription</key>
<string>Explain the user-visible feature that reads health data.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Explain the user-visible feature that writes health data.</string>

On Android, the consumer app must declare every Health Connect data permission it requests. The library deliberately does not add health data permissions to its own manifest.

| Data type | Read permission | Write permission | | ---------------------- | ------------------------------------------------------- | -------------------------------------------------------- | | steps | android.permission.health.READ_STEPS | android.permission.health.WRITE_STEPS | | distance | android.permission.health.READ_DISTANCE | android.permission.health.WRITE_DISTANCE | | activeEnergyBurned | android.permission.health.READ_ACTIVE_CALORIES_BURNED | android.permission.health.WRITE_ACTIVE_CALORIES_BURNED | | basalEnergyBurned | android.permission.health.READ_BASAL_METABOLIC_RATE | Not supported | | totalEnergyBurned | android.permission.health.READ_TOTAL_CALORIES_BURNED | Not supported | | hydration | android.permission.health.READ_HYDRATION | android.permission.health.WRITE_HYDRATION | | floorsClimbed | android.permission.health.READ_FLOORS_CLIMBED | android.permission.health.WRITE_FLOORS_CLIMBED | | heartRate | android.permission.health.READ_HEART_RATE | android.permission.health.WRITE_HEART_RATE | | bloodPressure | android.permission.health.READ_BLOOD_PRESSURE | android.permission.health.WRITE_BLOOD_PRESSURE | | bloodGlucose | android.permission.health.READ_BLOOD_GLUCOSE | android.permission.health.WRITE_BLOOD_GLUCOSE | | bodyTemperature | android.permission.health.READ_BODY_TEMPERATURE | android.permission.health.WRITE_BODY_TEMPERATURE | | respiratoryRate | android.permission.health.READ_RESPIRATORY_RATE | android.permission.health.WRITE_RESPIRATORY_RATE | | bodyFat | android.permission.health.READ_BODY_FAT | android.permission.health.WRITE_BODY_FAT | | leanBodyMass | android.permission.health.READ_LEAN_BODY_MASS | android.permission.health.WRITE_LEAN_BODY_MASS | | basalBodyTemperature | android.permission.health.READ_BASAL_BODY_TEMPERATURE | android.permission.health.WRITE_BASAL_BODY_TEMPERATURE | | restingHeartRate | android.permission.health.READ_RESTING_HEART_RATE | android.permission.health.WRITE_RESTING_HEART_RATE | | heartRateVariability | android.permission.health.READ_HEART_RATE_VARIABILITY | Not supported | | oxygenSaturation | android.permission.health.READ_OXYGEN_SATURATION | android.permission.health.WRITE_OXYGEN_SATURATION | | height | android.permission.health.READ_HEIGHT | android.permission.health.WRITE_HEIGHT | | vo2Max | android.permission.health.READ_VO2_MAX | android.permission.health.WRITE_VO2_MAX | | sleep | android.permission.health.READ_SLEEP | android.permission.health.WRITE_SLEEP | | bodyMass | android.permission.health.READ_WEIGHT | android.permission.health.WRITE_WEIGHT | | workout | android.permission.health.READ_EXERCISE | android.permission.health.WRITE_EXERCISE | | nutrition | android.permission.health.READ_NUTRITION | android.permission.health.WRITE_NUTRITION |

One nutrition permission covers every nutrition field. On iOS it fans out to the individual dietary quantity-type authorizations (one system-sheet row per nutrient), and getPermissionStatuses folds the write states worst-of: any denied nutrient reports notGranted for the whole grant.

Undeclared Health Connect permissions do not appear in the system permission sheet and cannot be granted. The Android privacy-policy rationale activity and provider package query are also consumer-app responsibilities; see example/android/app/src/main/AndroidManifest.xml for a complete reference.

Background and extended-history declarations are documented in Background Synchronization.

Raw Sample Model

Every raw sample returned by a read* method or a change upsert has required identity, origin, and recordingMethod fields plus optional device provenance and optional time-zone fields.

Time Zone

Every write input accepts an optional timeZone (IANA identifier, e.g. 'Asia/Tokyo'); omitted means the device's current zone at write time. Android stores it as the record's zone offset, resolved per instant — an interval crossing a DST shift gets differing start and end offsets. iOS stores the identifier itself as HKMetadataKeyTimeZone on every write. Invalid identifiers (including fixed offsets like '+01:00') reject.

Reads surface what the store retains, never a fabricated value:

  • zoneOffset? — portable UTC offset (e.g. "+09:00", always "+00:00" rather than "Z"). Android returns the record's stored offset (the start offset for intervals); iOS derives it from the stored zone name at the sample's start date. Absent when the writer stored no zone.
  • timeZone? — the IANA zone name, only available from HealthKit and only when the writer attached one. Always absent on Android, whose store keeps offsets only.

Change upserts reuse the same sample types, so both fields ride along in change tracking automatically.

Data Origin

interface HealthDataOrigin {
  identifier: string
  displayName?: string
}

origin.identifier is the stable application identifier supplied by the health service: an iOS bundle identifier or Android package name. origin.displayName is a human-readable app name when available. HealthKit supplies the source name; Health Connect currently supplies only the package name, so Android displayName is normally absent.

Do not use a display name as a database key. Use origin.identifier for stable source grouping.

Device Provenance

origin identifies the application that recorded a sample and is always assigned by the native health service. The optional device sibling describes the physical hardware asserted to have generated it:

interface HealthDeviceInfo {
  type?: HealthDeviceType
  manufacturer?: string
  model?: string
}

Health Connect supplies metadata.device, including a stable device category, while HealthKit supplies HKDevice.manufacturer and HKDevice.model but no canonical category. Consequently, device.type is normally absent on iOS. The supported categories are unknown, watch, phone, scale, ring, head-mounted, fitness-band, chest-strap, and smart-display; future or unsupported Health Connect categories fold to unknown.

Every top-level save input accepts optional device provenance. Android preserves type, manufacturer, and model; a missing Android type becomes unknown when manufacturer or model provides useful provenance. An unknown-only Android device is omitted, whether it came from an explicit { type: 'unknown' } input or from Health Connect's internal placeholder for an active or automatic record. iOS preserves manufacturer and model but cannot preserve the portable type, so a type-only device is omitted on iOS. origin is not writable and still identifies the app performing the save, even when that app imports data from another sensor.

device is omitted when the service exposes no useful projected fields. Android flattened heart-rate readings and sleep stages inherit their parent record's device. An iOS sleep session applies one supplied device to the envelope and every independently stored stage. Device values are caller-asserted provenance, not verified or stable identifiers: do not infer a missing type from model text or use manufacturer/model as a database key.

Recording Method

recordingMethod describes how a sample was captured. Read samples always include it; write inputs may omit it, which requests unknown.

| Public value | Meaning | Health Connect read/write mapping | HealthKit write mapping | HealthKit read mapping | | ------------------------ | ----------------------------- | ------------------------------------------------- | -------------------------------------- | ---------------------------------------------- | | manual | User-entered data | RECORDING_METHOD_MANUAL_ENTRY exactly | HKMetadataKeyWasUserEntered = true | true -> manual | | actively-recorded | User-initiated capture | RECORDING_METHOD_ACTIVELY_RECORDED exactly | No HKMetadataKeyWasUserEntered value | Not distinguishable; false/absent -> unknown | | automatically-recorded | Passive capture | RECORDING_METHOD_AUTOMATICALLY_RECORDED exactly | No HKMetadataKeyWasUserEntered value | Not distinguishable; false/absent -> unknown | | unknown | No more specific method known | RECORDING_METHOD_UNKNOWN exactly | No HKMetadataKeyWasUserEntered value | false/absent -> unknown |

Health Connect therefore preserves all four public values on writes and reads. HealthKit only exposes positive manual-entry metadata: false, an absent key, or any active/automatic write reads as unknown. Omitting recordingMethod from any save input defaults to unknown; on iOS, requested actively-recorded and automatically-recorded also degrade to stored unknown.

Record And Child Identity

type HealthSampleIdentity =
  | { kind: 'record'; id: string }
  | {
      kind: 'record-child'
      id: string
      record: { kind: 'record'; id: string }
    }
  • record identifies an independently deletable native record.
  • record-child identifies one flattened reading or stage owned by a parent record. Its id can be synthetic and unstable; record is the parent record identity.
  • Android heart-rate readings and sleep stages are record children because Health Connect stores several readings or stages inside one record.
  • iOS HealthKit samples are independently deletable records.

Use the identity tag rather than parsing an ID:

const { samples } = await NitroHealth.readHeartRate(query)

for (const sample of samples) {
  if (sample.identity.kind === 'record') {
    console.log('deletable sample', sample.identity.id)
  } else {
    console.log('child', sample.identity.id, 'parent', sample.identity.record.id)
  }

  console.log('recorded by', sample.origin.identifier)
}

Selecting a child sample's identity.record for deletion explicitly selects its whole parent. That removes every child reading or stage in the parent, not just the selected child.

Reading Data

All raw reads return { samples, nextCursor? }. Every listed sample also includes the common required identity, origin, and recordingMethod fields and may include device, zoneOffset, and timeZone (see Time Zone).

| Method | Data-specific sample fields | | -------------------------- | --------------------------------------------------------------------------------- | | readSteps | startDate, endDate, count | | readDistance | startDate, endDate, distanceMeters, scope | | readActiveEnergyBurned | startDate, endDate, kilocalories | | readHydration | startDate, endDate, milliliters | | readFloorsClimbed | startDate, endDate, floors | | readBodyMass | startDate, endDate, kilograms | | readHeartRate | date, bpm | | readBloodPressure | date, systolicMmHg, diastolicMmHg | | readBloodGlucose | date, millimolesPerLiter | | readBodyTemperature | date, celsius | | readRespiratoryRate | date, breathsPerMinute | | readBodyFat | date, percentage | | readLeanBodyMass | date, kilograms | | readBasalBodyTemperature | date, celsius | | readRestingHeartRate | date, bpm | | readHeartRateVariability | date, milliseconds, method | | readOxygenSaturation | date, percentage | | readHeight | date, meters | | readVo2Max | date, millilitersPerKilogramPerMinute | | readSleepSamples | tagged session-envelope or stage fields | | readWorkouts | workout duration, activity, labels, and metric availability | | readNutrition | startDate, endDate, plus optional foodName, mealType, and nutrient fields |

readNutrition returns one sample per eating event with the optional fields foodName, mealType, energyKilocalories, proteinGrams, totalCarbohydrateGrams, totalFatGrams, dietaryFiberGrams, sugarGrams, and sodiumMilligrams; absent nutrients are omitted, never zero-filled. Android maps a Health Connect NutritionRecord one-to-one. iOS reads food HKCorrelations, so dietary samples written by other apps outside a correlation are not visible to this read. Water is never part of a nutrition entry; hydration remains the only water API.

floorsClimbed is the portable API name. Android values come from Health Connect floors climbed; iOS values come from HealthKit flights climbed and are exposed unchanged as floors. A flight and a building floor are not guaranteed to be physically equivalent.

const page = await NitroHealth.readSteps({
  startDate: new Date('2026-01-01T00:00:00.000Z'),
  endDate: new Date('2026-01-02T00:00:00.000Z'),
  limit: 100,
  ascending: true,
})

for (const sample of page.samples) {
  console.log(sample.count, sample.identity, sample.origin, sample.device, sample.recordingMethod)
}

startDate is inclusive and endDate is exclusive. limit defaults to 1000 and ascending defaults to true. An empty successful query returns samples: [] without nextCursor.

Pagination

Pass the opaque nextCursor back to the same read with the same range and sort direction:

let cursor: string | undefined

do {
  const page = await NitroHealth.readSteps({
    startDate,
    endDate,
    limit: 500,
    ascending: true,
    cursor,
  })

  await store(page.samples)
  cursor = page.nextCursor
} while (cursor !== undefined)

Cursors are platform-specific, method-specific, query-specific, and short-lived. Do not parse, construct, transfer, or persist them. Invalid or foreign cursors reject.

Android heart-rate and sleep reads page by parent Health Connect record. One record can flatten to multiple returned samples, so a page can contain more samples than limit. iOS limits independent samples directly. In either case, nextCursor is present whenever another page remains.

Distance Scope

Distance results state what activity coverage the native record represents:

type DistanceScope = 'walking-running' | 'activity-unspecified'

HealthKit uses the walking/running distance quantity, so iOS raw samples and statistics return scope: 'walking-running'. A Health Connect DistanceRecord does not preserve that distinction, so Android raw samples and statistics return scope: 'activity-unspecified'. Do not compare or merge cross-platform distance totals without considering the scope.

Blood Pressure

One BloodPressureSample always carries both values in millimeters of mercury:

interface BloodPressureSample extends HealthSample {
  date: Date
  systolicMmHg: number
  diastolicMmHg: number
  metadata?: {
    android?: {
      bodyPosition?: 'unknown' | 'standing_up' | 'sitting_down' | 'lying_down' | 'reclining'
      measurementLocation?:
        'unknown' | 'left_wrist' | 'right_wrist' | 'left_upper_arm' | 'right_upper_arm'
    }
  }
}

Android maps a Health Connect BloodPressureRecord one-to-one. On iOS a reading is stored as an HKCorrelation containing separate systolic and diastolic HKQuantitySample members: saveBloodPressure() writes the correlation and both members in one atomic call, readBloodPressure() returns one sample per correlation, and identity is the correlation record on both platforms (kind: 'record'). Other HealthKit consumers can see the member samples as individual systolic/diastolic readings — that is how HealthKit models blood pressure, not a duplicate write.

A malformed third-party correlation (missing or duplicated member samples) rejects the read rather than fabricating a value. Deletion by identity or time range removes the correlation together with the member samples this app wrote. Health Connect's bodyPosition and measurementLocation have no HealthKit counterpart, so they live under the typed metadata.android scope instead of portable top-level fields. Android reads and change upserts return both values, including explicit unknown; omitted write values use Health Connect's *_UNKNOWN constants. iOS omits this metadata and ignores Android-scoped write fields. Blood pressure statistics are not supported by readStatistics() yet.

await NitroHealth.saveBloodPressure([
  {
    date: new Date(),
    systolicMmHg: 118,
    diastolicMmHg: 76,
    metadata: {
      android: {
        bodyPosition: 'sitting_down',
        measurementLocation: 'left_upper_arm',
      },
    },
  },
])

Blood Glucose

One BloodGlucoseSample carries the concentration in millimoles per liter (multiply by 18.0182 for mg/dL):

interface BloodGlucoseSample extends HealthSample {
  date: Date
  millimolesPerLiter: number
  metadata?: {
    android?: {
      specimenSource?:
        | 'unknown'
        | 'interstitial_fluid'
        | 'capillary_blood'
        | 'plasma'
        | 'serum'
        | 'tears'
        | 'whole_blood'
      mealType?: 'unknown' | 'breakfast' | 'lunch' | 'dinner' | 'snack'
      relationToMeal?: 'unknown' | 'general' | 'fasting' | 'before_meal' | 'after_meal'
    }
    ios?: {
      mealTime?: 'preprandial' | 'postprandial'
    }
  }
}

Android maps a Health Connect BloodGlucoseRecord one-to-one; iOS stores an HKQuantitySample in a composed mmol/L unit, so neither platform converts the value in JavaScript. Their nonportable fields live under typed platform scopes: Health Connect exposes specimenSource, mealType, and relationToMeal, while HealthKit exposes only mealTime. Android reads and change upserts return all three Android values, including explicit unknown; omitted Android write values use Health Connect's *_UNKNOWN constants. iOS returns metadata.ios only when HealthKit stored its meal-time key. Each platform ignores the other platform's explicitly scoped write fields. Blood glucose statistics are not supported by readStatistics().

await NitroHealth.saveBloodGlucose([
  {
    date: new Date(),
    millimolesPerLiter: 4.9,
    metadata: {
      android: { relationToMeal: 'fasting', specimenSource: 'capillary_blood' },
      ios: { mealTime: 'preprandial' },
    },
  },
])

Body Temperature

One BodyTemperatureSample carries the reading in degrees Celsius (°F = °C × 9/5 + 32):

interface BodyTemperatureSample extends HealthSample {
  date: Date
  celsius: number
  metadata?: BodyTemperatureMetadata
}

type AndroidBodyTemperatureMeasurementLocation =
  | 'unknown'
  | 'armpit'
  | 'finger'
  | 'forehead'
  | 'mouth'
  | 'rectum'
  | 'temporal_artery'
  | 'toe'
  | 'ear'
  | 'wrist'
  | 'vagina'

type IOSBodyTemperatureSensorLocation =
  | 'other'
  | 'armpit'
  | 'body'
  | 'ear'
  | 'finger'
  | 'gastro_intestinal'
  | 'mouth'
  | 'rectum'
  | 'toe'
  | 'ear_drum'
  | 'temporal_artery'
  | 'forehead'

Android maps a Health Connect BodyTemperatureRecord one-to-one; iOS stores an HKQuantitySample in HKUnit.degreeCelsius(). Measurement location is preserved through typed platform scopes because the native value sets overlap but are not identical: use metadata.android.measurementLocation for Health Connect and metadata.ios.sensorLocation for HealthKit. Omitted Android values use unknown; omitted iOS values attach no sensor-location metadata. Body temperature statistics are not supported by readStatistics().

await NitroHealth.saveBodyTemperature([
  {
    date: new Date(),
    celsius: 37.2,
    metadata: {
      android: { measurementLocation: 'mouth' },
      ios: { sensorLocation: 'mouth' },
    },
  },
])

Respiratory Rate

One RespiratoryRateSample carries the reading in breaths per minute:

interface RespiratoryRateSample extends HealthSample {
  date: Date
  breathsPerMinute: number
}

Android maps a Health Connect RespiratoryRateRecord one-to-one (its rate field is already breaths per minute); iOS stores an HKQuantitySample in count/min, so neither platform converts the value in JavaScript. Respiratory rate statistics are not supported by readStatistics().

Body Fat

One BodyFatSample carries the reading in percent of total body mass:

interface BodyFatSample extends HealthSample {
  date: Date
  percentage: number
}

HealthKit stores body fat as a fraction (0-1); Health Connect stores 0-100. The JS surface is always 0-100 — iOS converts natively on read and write, so the same save round-trips to the same value on both platforms. Body fat statistics are not supported by readStatistics() (Health Connect exposes no aggregate metrics for BodyFatRecord).

Lean Body Mass

One LeanBodyMassSample carries the reading in kilograms:

interface LeanBodyMassSample extends HealthSample {
  date: Date
  kilograms: number
}

Android maps a Health Connect LeanBodyMassRecord one-to-one; iOS stores an HKQuantitySample in kilograms, so neither platform converts the value in JavaScript. Lean body mass statistics are not supported by readStatistics().

Basal Body Temperature

One BasalBodyTemperatureSample carries the reading in degrees Celsius (°F = °C × 9/5 + 32):

interface BasalBodyTemperatureSample extends HealthSample {
  date: Date
  celsius: number
  metadata?: BodyTemperatureMetadata
}

Android maps a Health Connect BasalBodyTemperatureRecord one-to-one; iOS stores an HKQuantitySample in HKUnit.degreeCelsius(). It uses the same typed BodyTemperatureMetadata contract as body temperature for reads, writes, and change upserts. Basal body temperature statistics are not supported by readStatistics().

VO2 Max

One Vo2MaxSample carries the reading in milliliters of oxygen per kilogram of body mass per minute:

interface Vo2MaxSample extends HealthSample {
  date: Date
  millilitersPerKilogramPerMinute: number
  metadata?: {
    android?: {
      measurementMethod?:
        | 'other'
        | 'metabolic_cart'
        | 'heart_rate_ratio'
        | 'cooper_test'
        | 'multistage_fitness_test'
        | 'rockport_fitness_test'
    }
    ios?: {
      testType?:
        | 'max_exercise'
        | 'prediction_sub_max_exercise'
        | 'prediction_non_exercise'
        | 'prediction_step_test'
    }
  }
}

Android maps a Health Connect Vo2MaxRecord one-to-one (its vo2MillilitersPerMinuteKilogram field is the same unit); iOS stores an HKQuantitySample in ml/(kg·min), so neither platform converts the value in JavaScript. Their nonportable classifications remain separate: Health Connect's measurementMethod names a test protocol, while HealthKit's HKMetadataKeyVO2MaxTestType names a measurement class. Android reads and change upserts always return metadata.android.measurementMethod, including other; omitted Android writes use other. iOS returns metadata.ios only when HealthKit stored its test-type key, and omitted iOS writes attach no test type. Writing prediction_step_test requires iOS 26 or later. Each platform ignores the other platform's explicitly scoped write field. VO2 max statistics are not supported by readStatistics().

await NitroHealth.saveVo2Max([
  {
    date: new Date(),
    millilitersPerKilogramPerMinute: 44,
    metadata: {
      android: { measurementMethod: 'multistage_fitness_test' },
      ios: { testType: 'max_exercise' },
    },
  },
])

Heart Rate Variability

readHeartRateVariability() returns method: 'sdnn' on iOS and method: 'rmssd' on Android. SDNN and RMSSD are different, non-comparable measures. Never mix samples with different method values in one average, chart, or trend. HRV is read-only because there is no portable value to write.

Sleep Records

readSleepSamples() returns one flat tagged array. It preserves complete session envelopes separately from explicit stage intervals:

const { samples } = await NitroHealth.readSleepSamples({ startDate, endDate })

for (const sample of samples) {
  if (sample.kind === 'session-envelope') {
    console.log(sample.startDate, sample.endDate, sample.stageData)
    // stageData: 'reported' | 'not-reported'
  } else {
    console.log(sample.startDate, sample.endDate, sample.stage)
    if (sample.identity.kind === 'record-child') console.log(sample.identity.record)
  }
}

A session envelope has kind: 'session-envelope', bounds, stageData, and — on Android, when stored — metadata: { android: { title, notes } }. It does not have a stage. A stage has kind: 'stage', bounds, and stage; parent ownership is encoded by a record-child identity.

  • Android returns one record-identity envelope for every SleepSessionRecord, followed by its record-child stages. stageData is reported when explicit stages exist and not-reported when none exist.
  • iOS returns every HealthKit inBed interval as a session envelope and every other sleep category interval as an independent stage record. HealthKit does not link stages to their envelope, so iOS envelopes always carry stageData: 'not-reported' and only appear when the source app wrote an in-bed interval — stage-only apps produce no envelope.
  • A session without stages remains only a session envelope. Nitro Health does not manufacture a synthetic asleep stage, and it does not reconstruct sessions from loose iOS stages.

Stages normalize to awake, awakeInBed, asleep, asleepCore, asleepDeep, asleepREM, outOfBed, or unknown.

Workouts

Workout reads preserve availability and mapping fidelity instead of collapsing platform differences into optional numbers:

const { samples: workouts } = await NitroHealth.readWorkouts({
  startDate,
  endDate,
  limit: 50,
  ascending: false,
})

for (const workout of workouts) {
  console.log(workout.elapsedDurationSeconds)

  if (workout.activeDuration.status === 'available') {
    console.log(workout.activeDuration.value)
  }

  if (workout.activity.status === 'known') {
    console.log(workout.activity.type, workout.activity.portability, workout.activity.mapping)
  } else {
    console.log('unknown native activity')
  }

  console.log(workout.title, workout.brandName)
  console.log(workout.totalDistance, workout.totalActiveEnergyBurned)
}

elapsedDurationSeconds is always wall-clock endDate - startDate. activeDuration is pause-aware when reported. HealthKit returns it as available; the Health Connect exercise-session record does not expose it, so Android returns unsupported.

activity is either { status: 'unknown' } or a known activity with:

  • type: the normalized WorkoutActivityType.
  • portability: portable when the normalized value is also accepted for cross-platform writes, otherwise read-only.
  • mapping: exact when the normalized type preserves native meaning, or broadened when a more specific native activity was folded into a broader type such as treadmill running to running.

Unknown or future native activity values remain explicitly unknown; they are not silently converted to other.

title and brandName are separate fields. Android supplies the exercise-session title and currently has no brand field. HealthKit supplies workout brand metadata and currently has no native session title in this mapping.

activeDuration, totalDistance, and totalActiveEnergyBurned are HealthMetricValue values:

type HealthMetricValue =
  { status: 'available'; value: number } | { status: 'not-reported' } | { status: 'unsupported' }

On iOS, total distance and active energy are available when the workout reports them and not-reported otherwise. Android currently returns unsupported for both exercise-session totals.

Aggregation

Use native aggregation rather than summing raw samples. HealthKit and Health Connect can account for overlapping sources in ways a JavaScript sum cannot.

const dailySteps = await NitroHealth.readStatistics('steps', {
  startDate: new Date('2026-01-01T00:00:00.000Z'),
  endDate: new Date('2026-01-08T00:00:00.000Z'),
  bucket: 'day',
  metrics: ['sum'],
})

const heartRate = await NitroHealth.readStatistics('heartRate', {
  startDate,
  endDate,
  bucket: 'hour',
  metrics: ['avg', 'min', 'max'],
})

// Reproducible daily buckets, independent of where the device currently is:
const tokyoDays = await NitroHealth.readStatistics('steps', {
  startDate,
  endDate,
  bucket: 'day',
  metrics: ['sum'],
  timeZone: 'Asia/Tokyo',
})

timeZone is an optional IANA identifier that day, week, and month buckets are computed in; omitted means the device's current zone at query time. Every returned bucket echoes the resolved zone as timeZone, so results stay self-describing after the fact. Invalid identifiers (including fixed offsets like "+01:00") reject; only real IANA names and "UTC" resolve.

| Data type | Metrics | Unit | | ---------------------------- | ------------------- | -------------------- | | steps | sum | count | | distance | sum | meters, plus scope | | activeEnergyBurned | sum | kcal | | basalEnergyBurned | sum | kcal | | totalEnergyBurned | sum | kcal | | hydration | sum | mL | | floorsClimbed | sum | count | | heartRate | avg, min, max | bpm | | restingHeartRate | avg, min, max | bpm | | height | avg, min, max | meters | | bodyMass | avg, min, max | kg | | sleep | duration | seconds | | workout | duration | seconds | | nutritionEnergyConsumed | sum | kcal | | nutritionProtein | sum | g | | nutritionTotalCarbohydrate | sum | g | | nutritionTotalFat | sum | g | | nutritionDietaryFiber | sum | g | | nutritionSugar | sum | g | | nutritionSodium | sum | mg |

On iOS, floorsClimbed statistics aggregate HealthKit flights climbed. See the raw-read portability note above.

sleep duration is time asleep per bucket, in seconds, with awake time excluded. Android uses Health Connect's native sleep-duration aggregate. HealthKit has no aggregate for category samples, so iOS computes it from raw intervals: asleep stage intervals are union-merged across sources (each minute counts once even when several apps recorded the same span), and an in-bed interval that no stage sample overlaps counts in full — matching Health Connect's rule for stage-less sessions. Because the platforms aggregate from their own stores, the same person's night can produce slightly different totals per platform when the underlying source data differs.

workout duration sums each workout's own declared duration (which can exclude pauses) per bucket, in seconds. Overlapping workouts are not merged, and a workout spanning a bucket boundary contributes to each bucket in proportion to its wall-clock overlap.

HRV, oxygen saturation, blood pressure, blood glucose, body temperature, respiratory rate, body fat, lean body mass, basal body temperature, and VO2 max statistics are not supported by readStatistics(), and neither is the raw nutrition type — nutrient aggregates go through the per-nutrient statistics types above. Invalid data-type/metric combinations reject before crossing the native boundary.

Nutrition Statistics

The seven nutrition* types (NutritionStatisticsDataType) are statistics-only: sum buckets backed by the same records as nutrition. They are covered by the nutrition permission and are never valid permission entries themselves — requesting { dataType: 'nutritionProtein' } as a permission rejects in JS.

Android aggregates the corresponding NutritionRecord field. iOS runs statistics over the dietary quantity types, which see the whole store: unlike readNutrition (correlations only), these sums include loose dietary samples written by other apps outside a food correlation. Water is excluded as everywhere — use hydration statistics.

const dailyProtein = await NitroHealth.readStatistics('nutritionProtein', {
  startDate,
  endDate,
  bucket: 'day',
  metrics: ['sum'],
})

Basal and Total Energy

basalEnergyBurned and totalEnergyBurned are aggregate-only (AggregateOnlyHealthDataType): they are accepted by readStatistics() and read permissions, and nothing else. There are no raw reads, writes, deletes, or change tracking, because the underlying stores disagree about what the raw data is — HealthKit stores basal energy as interval samples and has no total-energy type at all, while Health Connect stores basal metabolic rate (an instantaneous kcal/day rate) and has a first-class total-energy record.

Bucketed sums are portable, with per-platform semantics worth knowing before charting them:

  • basalEnergyBurned — iOS sums stored resting-energy samples, so a device with no basal-energy source returns no buckets. Android integrates stored metabolic-rate records over each bucket; when the user has no stored rate, Health Connect estimates one from body measurements, falling back to demographic defaults — a confident-looking number can be a population estimate rather than a reading about this user, and Android will practically always answer where iOS reports nothing.
  • totalEnergyBurned — Android reads stored total-energy records, deriving from components where none exist. iOS has no total-energy type, so each bucket is composed from active plus basal energy, and a bucket is emitted only when its basal half is present: a setup where an app writes workout energy but nothing writes resting energy returns no buckets rather than passing off the active half as the whole total.

Do not compare energy totals across platforms bucket-for-bucket; compare trends within one device. A missing bucket means "no data for this window", never "zero energy burned" — do not backfill it with 0.

On iOS a totalEnergyBurned read permission authorizes both component quantity types (active and basal energy), the same way blood pressure authorizes its two members. On Android the permissions are READ_BASAL_METABOLIC_RATE and READ_TOTAL_CALORIES_BURNED; see the permission table above.

Buckets anchor at startDate. Use local midnight for calendar-day buckets. week is a rolling seven-day interval from that anchor. The final bucket is clamped to endDate, empty buckets are omitted, and results are ascending. Hour buckets are fixed 3600-second intervals; day, week, and month buckets follow the resolved time zone — the query's timeZone when provided, otherwise the device zone at query time — so a day bucket containing a DST transition spans 23 or 25 physical hours. Every bucket echoes the resolved zone as timeZone.

readHeartRateStatistics({ startDate, endDate }) remains the whole-range heart-rate aggregate and returns { average?, min?, max? }.

Change Tracking

Change tokens are durable synchronization checkpoints. They are different from pagination cursors. Persist one token per ChangeTrackedHealthDataType and device/store.

Every raw-readable data type is change-tracked, including nutrition. Nutrition changes operate on the record that owns the entry — the food correlation on iOS, NutritionRecord on Android — so an upsert carries the complete current entry and a delete identifies the exact record that died, matching every other type's contract. Two nutrition-specific semantics worth knowing:

  • iOS versioned re-saves surface as a delete + upsert pair. HealthKit replaces a synced record instead of updating it in place, so a higher-version saveNutrition produces a delete of the old record identity followed by an upsert with a new one (Android updates in place and keeps its record id). This is the same replacement behavior as every other type; reconcile through the record identity in each change.
  • Background wake-ups observe the seven member nutrient types. HealthKit rejects correlation types for observer queries, so configureBackgroundChanges(['nutrition']) observes the dietary member types (energy, protein, carbohydrate, fat, fiber, sugar, sodium) and coalesces them into one nutrition notification. A loose third-party dietary sample outside any food correlation can therefore fire a spurious wake-up whose getChanges drain is empty — treat an empty drain as "nothing to do", never as an error.

Create the token before the initial snapshot so changes that occur while paging the snapshot are not lost:

import { NitroHealth, type HealthRecordChange } from 'react-native-nitro-health'

let changesToken = await NitroHealth.createChangesToken('steps')
await replaceInitialStepSnapshot()

for (;;) {
  const result = await NitroHealth.getChanges('steps', changesToken)

  if (result.tokenExpired) {
    changesToken = await NitroHealth.createChangesToken('steps')
    await replaceInitialStepSnapshot()
    continue
  }

  await database.transaction(async () => {
    for (const change of result.changes) {
      await applyStepChange(change)
    }

    // Commit the checkpoint only after every change in this page succeeds.
    await saveChangesToken(result.nextChangesToken)
  })

  changesToken = result.nextChangesToken
  if (!result.hasMore) break
}

async function applyStepChange(change: HealthRecordChange<'steps'>) {
  if (change.type === 'delete') {
    await removeSamplesForRecord(change.record.id)
    return
  }

  // An upsert is the complete current content of this parent record.
  await replaceSamplesForRecord(change.record.id, change.samples)
}

Change identity is always record-level: every change has record: { kind: 'record', id }. An upsert can contain one sample, multiple record-child samples, or an empty array, and its samples carry the same optional device provenance as normal reads. Replace all locally cached samples owned by change.record; never append an upsert blindly. A delete removes every cached child owned by that record.

Process changes in returned order, but do not treat that order as a cross-platform event timeline. Persist nextChangesToken only after the page commits. Reusing the input token safely replays the page; committing the next token early can lose changes. Serialize drains per data type, or use compare-and-swap persistence, so foreground and background sync cannot commit out of order. Continue immediately while hasMore is true.

Tokens are opaque, platform-specific, data-type-specific, and device/store-specific. Never parse, modify, or transfer them. Health Connect tokens expire after approximately 30 days and then return { tokenExpired: true }; create a new token before rebuilding the snapshot. HealthKit does not expose token expiration, though native query failures can still reject. The first iOS token creation drains existing anchored-query history internally to establish a current checkpoint and can take longer on a large store.

For versioned writes, sync.id is the app's logical identity while identity remains physical native identity. A replacement may keep or change its physical record ID depending on the health service. Synchronize from record changes rather than assuming IDs survive replacement.

Background Synchronization

Both background modes feed the same durable change-token drain described above:

  • Observer mode emits only a coalesced hint containing dataTypes; the app drains each type's last committed token.
  • Polling mode runs from an app-owned scheduler and drains those same tokens.

The app always owns token persistence, serialized drains, database transactions, retry policy, network policy, and initial/expired-token snapshots.

Configure, Disable, And Subscribe

The typed outcome tells the app whether native observer delivery completed or app-owned polling work remains:

const configured = await NitroHealth.configureBackgroundChanges({
  dataTypes: ['steps', 'sleep'],
  frequency: 'hourly',
})

if (configured.status === 'completed') {
  // configured.mode is 'observer'. Native observer configuration is active.
} else if (configured.status === 'user-action-required') {
  // configured.mode is 'polling' and scheduling is 'app-owned'.
  if (configured.backgroundRead === 'not-granted') {
    await NitroHealth.requestAdditionalAccess('background-read')
  }
  await scheduleAppOwnedHealthPolling()
} else {
  // Health data is unavailable.
}

Subscribe without checking the operating system:

const background = NitroHealth.subscribeToBackgroundChanges(({ dataTypes }) => {
  for (const dataType of dataTypes) {
    scheduleSerializedChangesDrain(dataType)
  }
})

if (background.mode === 'observer') {
  // Keep this cleanup handle for the listener lifetime.
  background.subscription.remove()
} else if (background.mode === 'polling') {
  // No listener was installed. Maintain the app-owned polling schedule.
  console.log(background.scheduling)
} else {
  console.log(`Health service unavailable: ${background.availability.reason}`)
}

In real startup code, retain an observer subscription until teardown rather than removing it immediately. Multiple observer listeners are supported; each returned subscription owns its cleanup. Removing a listener does not disable configured delivery.

Disable selected observer types, or omit the argument to disable all configured types:

const disabled = await NitroHealth.disableBackgroundChanges(['steps'])

if (disabled.status === 'user-action-required') {
  // Polling mode: cancel the corresponding app-owned scheduled work.
  await cancelAppOwnedHealthPolling(['steps'])
}

await NitroHealth.disableBackgroundChanges()

In polling mode, configure and disable cannot create or cancel the consumer's scheduler, so both return user-action-required. Observer frequencies are HealthKit scheduling hints, not timing guarantees.

iOS Observer Bootstrap And Retention

iOS observer configuration is persisted by the library and restored automatically: a load-time constructor in the pod re-registers all configured observers when the application finishes launching, so terminated-app background delivery works with no AppDelegate code, bridging header, or other consumer setup. Pending data-type hints are retained and coalesced until a JavaScript listener receives them. The library acknowledges a native delivery after the current JavaScript listeners have run. Always drain the durable token because a hint contains no records and can be delayed, duplicated, or coalesced.

The automatic bootstrap does nothing unless the app previously called configureBackgroundChanges() — for apps that never enable background delivery it is a single UserDefaults read at launch.

The only remaining consumer step is the HealthKit background-delivery entitlement on the app target (Expo prebuild apps apply it through a config plugin; this package does not currently ship one):

<key>com.apple.developer.healthkit.background-delivery</key>
<true/>

HealthKit exposes observer delivery as an iOS capability, but does not provide an iOS API for inspecting the host app's signed background-delivery entitlement. A missing entitlement therefore causes configureBackgroundChanges() to reject rather than changing the reported capability to polling.

Upgrading from the manual bootstrap: delete the void NitroHealthRegisterPersistedObservers(void); declaration (or #import <NitroHealth/NitroHealthBackgroundDelivery.h>) from the app's bridging header and the NitroHealthRegisterPersistedObservers() call from the AppDelegate — the function and its header no longer exist, and leaving the call in place fails at link time.

HealthKit can enforce slower minimum frequencies for some types, protected data can be unavailable while the device is locked, and force-quitting can prevent relaunch. Drain configured tokens on normal launch and foreground activation even when no hint was received. True server delivery and cold-launch behavior require a signed physical device; Simulator is insufficient.

Android Polling And Additional Permissions

Android has no application-facing Health Connect change observer. The app schedules WorkManager, an Expo background task, or another scheduler and drains change tokens when that work runs.

Declare background and extended-history permissions in the consumer manifest only when those workflows are used:

<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND" />
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY" />

These permissions do not replace data-type read permissions. getCapabilities() distinguishes unsupported, undeclared, ungranted, and granted states through Health Connect feature checks and manifest/grant inspection. After adding a missing declaration, rebuild the app before requesting it.

Each scheduled run should recheck availability, getCapabilities(), and relevant per-entry read permissions, then drain every token until hasMore is false. Handle token expiration with the token-before-snapshot sequence. Android configure/disable results never schedule or cancel work on the consumer's behalf.

Writing Data

Request write authorization before saving. Save methods exist for steps, distance, active energy, hydration, floors climbed, heart rate, blood pressure, blood glucose, body temperature, respiratory rate, body fat, lean body mass, basal body temperature, resting heart rate, oxygen saturation, height, VO2 max, body mass, sleep sessions, completed workouts, and nutrition entries. HRV remains read-only.

const authorization = await NitroHealth.requestAuthorization([
  { accessType: 'write', dataType: 'steps' },
])

const stepsWrite = authorization.statuses.find(
  ({ permission }) => permission.accessType === 'write' && permission.dataType === 'steps'
)

if (stepsWrite?.status === 'granted') {
  const result = await NitroHealth.saveSteps([
    {
      startDate: new Date('2026-01-01T09:00:00.000Z'),
      endDate: new Date('2026-01-01T09:30:00.000Z'),
      count: 512,
      device: { type: 'watch', manufacturer: 'Example', model: 'Running Watch' },
      recordingMethod: 'actively-recorded',
      sync: { id: 'morning-walk', version: 1 },
    },
  ])

  console.log(result.status, result.storedRecordingMethods[0])
}

Every save resolves to { status: 'completed', storedRecordingMethods } when the native operation succeeds. storedRecordingMethods has one entry per top-level input in the same order, reporting what the native store retained rather than merely echoing the request. For versioned writes, the native implementation reads the retained record back when read access is available; with write-only access, it reports the platform-normalized submitted method because neither platform exposes the retained lower-version record through its save response. saveWorkout() accepts one top-level workout, so its array always has length one. saveDistance() adds storedScope beside the same storedRecordingMethods array.

device can be supplied on every top-level write input. It does not change the platform-owned origin. For versioned writes, a higher version replaces device provenance with the replacement payload and a lower version leaves the retained device unchanged. Write results do not echo retained device metadata; read the stored sample when confirmation is required.

The main value constraints are:

  • saveSteps: positive integer count, at most 1,000,000.
  • saveDistance: non-negative distanceMeters, at most 1,000,000, plus required scope intent.
  • saveActiveEnergyBurned: non-negative kilocalories, at most 1,000,000.
  • saveHydration: non-negative milliliters, at most 100,000.
  • saveFloorsClimbed: non-negative floors, at most 1,000,000.
  • saveHeartRate and saveRestingHeartRate: bpm from 1 through 300. Android rounds to whole bpm.
  • saveBloodPressure: systolicMmHg from 20 through 200 and diastolicMmHg from 10 through 180.
  • saveBloodGlucose: millimolesPerLiter from 0.5 through 50.
  • saveBodyTemperature: celsius from 20 through 45.
  • saveRespiratoryRate: breathsPerMinute from 0 through 120.
  • saveBodyFat: percentage from 0 through 100.
  • saveLeanBodyMass: kilograms greater than 0 and at most 1,000.
  • saveBasalBodyTemperature: celsius from 20 through 45.
  • saveOxygenSaturation: percentage from 0 through 100.
  • saveHeight: meters greater than 0 and at most 3.
  • saveVo2Max: millilitersPerKilogramPerMinute from 0 through 100.
  • saveBodyMass: kilograms greater than 0 and at most 1,000.
  • saveNutrition: at least one nutrient field per entry; every nutrient is non-negative and at most 100,000 (energyKilocalories in kcal, proteinGrams/totalCarbohydrateGrams/totalFatGrams/dietaryFiberGrams/sugarGrams in grams, sodiumMilligrams in mg). foodName must be non-blank when present; mealType is breakfast, lunch, dinner, or snack.

Interval inputs require startDate < endDate; point measurements use date. Batch saves require a non-empty array.

Every write input can include sync: { id, version } for retry-safe versioned writes. Exact retries are idempotent in stored state, a higher version replaces the logical record, and a lower version is ignored. Increment version whenever payload changes. IDs are nonblank, case-sensitive, scoped to the app and data type, and unique within one batch.

A sleep session maps to several independent HealthKit samples, so on iOS the session's sync.id goes on the envelope and each stage sample stores a derived identifier (<id>#stage0, <id>#stage1, … over the chronologically sorted stages). A versioned re-save replaces every sample of the session, and stages dropped from the new payload are removed. On Android the session is one record and sync maps directly to its client record identity.

Avoid overlapping cumulative writes. Health Connect and HealthKit aggregate overlaps differently even though raw reads preserve every stored record.

Write Distance

Distance writes require explicit walking/running intent:

const result = await NitroHealth.saveDistance([
  {
    scope: 'walking-running',
    startDate,
    endDate,
    distanceMeters: 1250,
    recordingMethod: 'manual',
    sync: { id: 'walk-2026-01-01', version: 1 },
  },
])

console.log(result.status, result.storedScope, result.storedRecordingMethods[0])

The only accepted input scope is walking-running. The returned { status: 'completed', storedScope, storedRecordingMethods } reports what the native store retained. HealthKit stores walking/running distance and returns walking-running. Health Connect stores a general DistanceRecord without activity scope and returns activity-unspecified. The result makes this loss of specificity explicit.

Write Sleep Sessions

await NitroHealth.saveSleepSessions([
  {
    startDate: new Date('2026-01-11T03:00:00.000Z'),
    endDate: new Date('2026-01-11T11:30:00.000Z'),
    timeZone: 'America/New_York',
    device: { type: 'watch', manufacturer: 'Example', model: 'Sleep Watch' },
    stages: [
      {
        startDate: new Date('2026-01-11T03:15:00.000Z'),
        endDate: new Date('2026-01-11T06:30:00.000Z'),
        stage: 'asleepCore',
      },
      {
        startDate: new Date('2026-01-11T06:30:00.000Z'),
        endDate: new Date('2026-01-11T08:00:00.000Z'),
        stage: 'asleepDeep',
      },
    ],
  },
])

Writable stages are awake, asleep, asleepCore, asleepDeep, and asleepREM. Stages must have positive duration, stay inside the session, and not overlap. Gaps and adjacent intervals are allowed. timeZone is an optional IANA identifier and defaults to the device time zone. Device provenance belongs to the session and is applied to every stored stage; stages do not accept conflicting device fields.

Sessions accept optional platform-scoped metadata: metadata: { android: { title, notes } } stores the Health Connect session title and