Health Connect in React Native and Expo, synced to your server

HC Sync connects React Native and Expo Health Connect reads to your server through REST, signed webhooks and MCP. The wrapper uploads changed records read by react-native-health-connect. Health Connect needs Android minSdk 26. Expo requires a native development build, and the config plugin requires an explicit non-empty list of data types.

Last updated: 2026-11-04. Source for the 2026 service details: HC Sync page details.

How do you install the React Native dependencies?

npx expo install react-native-health-connect expo-build-properties @react-native-async-storage/async-storage
npm install hcsync-react-native

How do you configure the Expo plugin?

In app.json, add the plugins. Health Connect needs minSdk 26, set through expo-build-properties; our plugin does the rest of the Android setup:

"plugins": [
  "react-native-health-connect",
  ["expo-build-properties", { "android": { "compileSdkVersion": 36, "targetSdkVersion": 36, "minSdkVersion": 26 } }],
  ["hcsync-react-native", { "types": ["Steps", "SleepSession"] }]
]

The Expo plugin (no native editing)

On expo prebuild or an EAS build, hcsync-react-native writes the manifest for you: the android.permission.health.READ_* permissions for the types you list (an explicit non-empty array is required; missing types fail the build), the androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE filter and the Android 14 VIEW_PERMISSION_USAGE alias, both pointing at .MainActivity (set "activity" to use another), and <queries> for com.google.android.apps.healthdata. Request only what you use: Play Console asks you to justify each permission, and your privacy policy must say data goes to your servers. Build a dev client: Health Connect doesn't run in Expo Go.

How do you connect and sync?

The package (typed, MIT) is hcsync-react-native; you can also copy the single source file, hcsync-react-native.ts. It backfills 30 days on first run, then uploads only changes (including deletions) using Health Connect change tokens, in batches of 1,000, with the same record shape as our Android SDK: steps, distance, active and total calories, heart rate, resting heart rate, HRV, SpO2, sleep with stages, exercise and weight. Pass the same types to the runtime as the Expo plugin. Reads and change tracking use only configured types with currently granted read permissions; permission changes start a separate sync position. Sync positions are kept per user, so account switches never mix data.

import { AppState } from "react-native";
import { connectHealth, syncHealthOnce, resetHealthSync } from "hcsync-react-native";

// From a button the user tapped:
const types = ["Steps", "SleepSession"] as const; // same types as app.json
await connectHealth(types);

// On start and whenever the app returns to the foreground:
const userToken = await fetchHcSyncTokenFromYourServer(); // POST /v1/user-tokens with your secret key
syncHealthOnce(userToken, types);
AppState.addEventListener("change", (s) => s === "active" && syncHealthOnce(userToken, types));

// On sign-out:
await resetHealthSync(userToken);

How do you read it on your server?

curl "https://hcsync.dev/v1/summary?user_id=maya&days=7" -H "authorization: Bearer $READ_KEY"

Or set a webhook and receive signed records.synced events. Try it all before touching a phone: create a free project and press Load a sample week.

What lifecycle constraints should you know?

Where can you inspect the module?

// HC Sync for React Native / Expo (Android). Reads Health Connect with react-native-health-connect
// and uploads changes to HC Sync. Call syncHealth() on app start and when the app returns to the foreground.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getChanges, getGrantedPermissions, getSdkStatus, initialize, readRecords, requestPermission, SdkAvailabilityStatus } from "react-native-health-connect";

const API = "https://hcsync.dev";
const TYPES = ["Steps", "Distance", "ActiveCaloriesBurned", "TotalCaloriesBurned", "HeartRate", "RestingHeartRate",
  "HeartRateVariabilityRmssd", "OxygenSaturation", "SleepSession", "ExerciseSession", "Weight"] as const;
export type HcType = (typeof TYPES)[number];
// Change tokens are stored per user token, so switching accounts never reuses another user's position.
const selected = (types: readonly HcType[]) => TYPES.filter((t) => types.includes(t));
const tokenKey = (userToken: string) => `hcsync.changes.${userToken.split(".")[1] ?? userToken.slice(-16)}`;

/** Ask once, from a button the user tapped. Returns false if Health Connect is missing or nothing was granted. */
export async function connectHealth(types: readonly HcType[]) {
  if ((await getSdkStatus()) !== SdkAvailabilityStatus.SDK_AVAILABLE || !(await initialize())) return false;
  const granted = await requestPermission(selected(types).map((recordType) => ({ accessType: "read" as const, recordType })));
  return selected(types).some((t) => granted.some((p) => p.accessType === "read" && p.recordType === t));
}

/** Converts one Health Connect record to HC Sync's {id, type, start, end, value, source} rows (same shape as the Android SDK). */
function rows(t: HcType, r: any): any[] {
  const base = (id: string, type: string, start: string, end: string | null, value: object) =>
    ({ id, type, start, end, value, source: r.metadata?.dataOrigin ?? null });
  const id = r.metadata.id as string;
  switch (t) {
    case "Steps": return [base(id, "steps", r.startTime, r.endTime, { count: r.count })];
    case "Distance": return [base(id, "distance", r.startTime, r.endTime, { meters: r.distance.inMeters })];
    case "ActiveCaloriesBurned": return [base(id, "active_calories", r.startTime, r.endTime, { kcal: r.energy.inKilocalories })];
    case "TotalCaloriesBurned": return [base(id, "total_calories", r.startTime, r.endTime, { kcal: r.energy.inKilocalories })];
    case "HeartRateVariabilityRmssd": return [base(id, "hrv", r.time, null, { rmssd_ms: r.heartRateVariabilityMillis })];
    case "OxygenSaturation": return [base(id, "spo2", r.time, null, { percent: r.percentage })];
    case "ExerciseSession": return [base(id, "exercise", r.startTime, r.endTime, { exercise_type: r.exerciseType, title: r.title ?? null })];
    case "RestingHeartRate": return [base(id, "resting_heart_rate", r.time, null, { bpm: r.beatsPerMinute })];
    case "Weight": return [base(id, "weight", r.time, null, { kg: r.weight.inKilograms })];
    case "SleepSession": return [base(id, "sleep", r.startTime, r.endTime,
      { stages: (r.stages ?? []).map((s: any) => ({ stage: s.stage, start: s.startTime, end: s.endTime })) })];
    case "HeartRate": { // parts of ≤300 samples with stable ids, like the Android SDK
      const parts: any[][] = [];
      for (let i = 0; i < r.samples.length; i += 300) parts.push(r.samples.slice(i, i + 300));
      if (!parts.length) return [base(`${id}#0`, "heart_rate", r.startTime, r.endTime, { n: 0, parts: 0 })];
      return parts.map((p, i) => base(`${id}#${i}`, "heart_rate", p[0].time, p[p.length - 1].time, {
        bpm: p.reduce((a, s) => a + s.beatsPerMinute, 0) / p.length, n: p.length, parts: parts.length,
        samples: p.map((s) => ({ t: s.time, bpm: s.beatsPerMinute })) }));
    }
  }
}

const post = async (path: string, userToken: string, body: object) => {
  const res = await fetch(API + path, { method: "POST", headers: { authorization: `Bearer ${userToken}`, "content-type": "application/json" }, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`HC Sync ${path} ${res.status}: ${(await res.json().catch(() => ({}))).error ?? ""}`);
  return res.json();
};

async function upload(userToken: string, recs: any[]) {
  let stored = 0, rejected = 0;
  for (let i = 0; i < recs.length; i += 1000) {
    const r = await post("/v1/sync", userToken, { records: recs.slice(i, i + 1000) });
    stored += r.stored; rejected += r.rejected?.length ?? 0;
  }
  return { stored, rejected };
}

const typeOf = (r: any): HcType | undefined => TYPES.find((t) => t === r.recordType);

/** Uploads what changed since the last call; the first call backfills 30 days. userToken comes from your server (POST /v1/user-tokens). */
export async function syncHealth(userToken: string, types: readonly HcType[]) {
  if (!(await initialize())) return { stored: 0, rejected: 0, deleted: 0 };
  const granted = await getGrantedPermissions();
  const allowed = selected(types).filter((t) => granted.some((p) => p.accessType === "read" && p.recordType === t));
  const prefix = tokenKey(userToken) + ".";
  let stored = 0, rejected = 0, deleted = 0;
  // A token containing a revoked type cannot be read. Keep independent positions for each type.
  const keys = (await AsyncStorage.getAllKeys()).filter((k) => k.startsWith(prefix));
  for (const key of keys) {
    const previous = key.slice(prefix.length).split(",") as HcType[];
    if (previous.length === 1 && allowed.includes(previous[0])) continue;
    // Migrate old combined tokens while all their permissions are still available.
    if (previous.every((t) => allowed.includes(t))) {
      let token = await AsyncStorage.getItem(key);
      while (token) {
        const c = await getChanges({ recordTypes: previous, changesToken: token });
        if (c.changesTokenExpired) break;
        const u = await upload(userToken, c.upsertionChanges.flatMap(({ record }: any) => {
          const t = typeOf(record); return t && allowed.includes(t) ? rows(t, record) : [];
        }));
        stored += u.stored; rejected += u.rejected;
        const ids = c.deletionChanges.map((d) => d.recordId);
        if (ids.length) deleted += (await post("/v1/sync/delete", userToken, { ids })).deleted;
        token = c.nextChangesToken;
        await AsyncStorage.setItem(key, token);
        if (!c.hasMore) break;
      }
    }
    await AsyncStorage.removeItem(key);
  }
  for (const t of allowed) {
    const key = prefix + t;
    let token = (await AsyncStorage.getItem(key)) ?? undefined;
    if (token) {
      for (;;) {
        const c = await getChanges({ recordTypes: [t], changesToken: token });
        if (c.changesTokenExpired) { await AsyncStorage.removeItem(key); token = undefined; break; }
        const u = await upload(userToken, c.upsertionChanges.flatMap(({ record }: any) => typeOf(record) === t ? rows(t, record) : []));
        stored += u.stored; rejected += u.rejected;
        const ids = c.deletionChanges.map((d) => d.recordId);
        if (ids.length) deleted += (await post("/v1/sync/delete", userToken, { ids })).deleted;
        token = c.nextChangesToken;
        await AsyncStorage.setItem(key, token);
        if (!c.hasMore) break;
      }
      if (token) continue;
    }
    const next = (await getChanges({ recordTypes: [t] })).nextChangesToken;
    const startTime = new Date(Date.now() - 30 * 864e5).toISOString(), endTime = new Date().toISOString();
    let pageToken: string | undefined;
    do {
      const page: any = await readRecords(t, { timeRangeFilter: { operator: "between", startTime, endTime }, pageToken });
      const u = await upload(userToken, page.records.flatMap((r: any) => rows(t, r)));
      stored += u.stored; rejected += u.rejected; pageToken = page.pageToken || undefined;
    } while (pageToken);
    await AsyncStorage.setItem(key, next);
  }
  return { stored, rejected, deleted };
}

let running: Promise<unknown> | undefined;
/** Like syncHealth, but never runs two syncs at once (e.g. app start and foreground firing together). */
export const syncHealthOnce = (userToken: string, types: readonly HcType[]) => (running ??= syncHealth(userToken, types).finally(() => { running = undefined; }));

/** On sign-out: forget this user's sync position so the next sign-in starts clean. Server data is untouched. */
export async function resetHealthSync(userToken: string) {
  const prefix = tokenKey(userToken);
  await AsyncStorage.multiRemove((await AsyncStorage.getAllKeys()).filter((k) => k === prefix || k.startsWith(prefix + ".")));
}

What else should you know?

Does HC Sync work on iPhone?

Not yet. Health Connect is Android. Apple HealthKit is next if you ask for it.

How do I delete a user’s data?

DELETE /v1/users/{id} removes everything for that user immediately.

Where is data stored?

Records are stored in the US and deleted after 13 months.