Stridee
Stridee Platform Docs

Apple Health

New

Apple Watch and iPhone workouts and wellness from HealthKit, pushed from your own iOS app by the StrideeHealth SDK and delivered like any other provider's.

HealthKit has no server API. An Apple Watch's runs and nights live on the iPhone, readable only by an app the user has granted access to — yours. There is no URL to send them to and no consent page of ours: the permission sheet is iOS's own, and the only thing that can read the data is code running in your app.

So the direction flips. Instead of us fetching from a provider, your app reads HealthKit and pushes to us, and our Swift package does the reading, the batching and the retrying. What reaches your webhook is the same as from Garmin: activity.created with a FIT file, wellness.created with the same metrics, under provider: "apple".

How it fits together

Your app must never hold your signing key — anyone with the IPA would have it, and it signs for every user on your account. So your server mints a short-lived device token for the one signed-in user, and the app pushes with that.

A device token is the same idea as a Stripe ephemeral key or a Plaid link token:

  • It lasts an hour. The SDK asks for a fresh one when it runs out.
  • It pushes for one user and reads nothing. A stolen one can add workouts to one person's history for the rest of its hour, and nothing else.
  • We keep only its SHA-256. We cannot show it back to you, and you should not store it either — mint one each time the app asks.

Calls made with one appear in your request log without a key id, since no key signed them.

Who talks to whom
Your app                     Your server                       Stridee
   │  "a token, please"           │                               │
   │  (your own session) ───────▶ │  POST /v1/device-tokens       │
   │                              │  signed ────────────────────▶ │
   │                              │ ◀──── { token: "sdt_…", … }   │
   │ ◀───────── token ─────────── │                               │
   │                                                              │
   │  Authorization: Bearer sdt_…                                 │
   │  PUT  /v1/device/connections/apple ────────────────────────▶ │ ─▶ account.connected
   │  POST /v1/device/apple/workouts ───────────────────────────▶ │ ─▶ activity.created
   │  POST /v1/device/apple/wellness ───────────────────────────▶ │ ─▶ wellness.created

1. Mint tokens on your server

Add a route to your own backend, behind your own sign-in, that calls this and hands the token to the app. Your server has already decided who the person is; that signed call is how it vouches for them to us.

post /v1/device-tokens
Reference

Mint a device token.

Call this from your server, when your app asks for one on behalf of a user who is signed in to it. The token lets the app push that user's Apple Health data directly to Stridee, so your signing key never has to leave your server and workouts never have to pass through it.

Mint freely: every call returns a new token, tokens expire after an hour, and a person may hold several at once — one per device.

Body parameters
external_user_idstringRequired

Your id for the person whose phone this is — the same value you would pass to POST /v1/connect. Creates them on first sight, so there is no separate call to register a user.

signRequest is the helper from Signing requests.

  • Mint per request. Every call returns a new token, and a user may hold several live ones at once — one per device is normal.
  • external_user_id is the same id you pass to /v1/connect. It creates the user on first sight, so someone with an Apple Watch and a Garmin is one user with two connections, not two users.
POST /v1/device-tokens
POST /v1/device-tokens HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "external_user_id": "user_4821"
}
Response · 201
{
  "expires_at": "2026-08-05T11:44:02Z",
  "token": "string",
  "user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}
device-token.js
import { signRequest } from './sign-request.js'; // from "Signing requests"

app.post('/stridee/device-token', requireUser, async (req, res) => {
  const url = 'https://api.stridee.com/v1/device-tokens';
  const body = JSON.stringify({ external_user_id: req.user.id });

  const response = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...signRequest({ method: 'POST', url, body }) },
    body,
  });

  const { token, expires_at } = await response.json();
  res.json({ token, expires_at });
});

2. Add the SDK

StrideeHealth is a Swift package for iOS 17 and later. In Xcode, File → Add Package Dependencies… and paste:

Then three things in your app target. Miss the first two and nothing fails loudly — data simply stops arriving whenever the app is not open.

HealthKit capabilitySigning & Capabilities → + Capability → HealthKit.
Background DeliveryTick it under the HealthKit capability. It adds com.apple.developer.healthkit.background-delivery, which lets iOS wake your app when a new workout lands.
NSHealthShareUsageDescriptionThe sentence on iOS's permission sheet. Without it, asking for access crashes the app. The SDK only reads, so you don't need NSHealthUpdateUsageDescription.
Package URL
https://github.com/stridee-fit/stridee-health-ios
YourApp.entitlements
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
Info.plist
<key>NSHealthShareUsageDescription</key>
<string>YourApp reads your workouts, sleep and heart rate to build your training plan.</string>

3. Configure it at launch

Call configure in application(_:didFinishLaunchingWithOptions:) — every launch, not only when the user taps something. When iOS wakes your app in the background to deliver new health data, it is this call that registers the observers that receive it; configure later and the wake-up is wasted. A SwiftUI app uses @UIApplicationDelegateAdaptor to get a delegate.

tokenProvider is the only required setting. The rest:

SettingDefault
dataTypes[.workouts, .wellness]Drop one to sync only the other.
excludedSourceBundleIDsGarmin Connect and WahooWorkouts written into HealthKit by these apps are skipped. If you also connect those providers, their workouts already arrive natively, and syncing the HealthKit copy would deliver each run twice.
wellnessHistoryDays90How far back wellness goes on the first sync.
baseURLhttps://api.stridee.comChange it only if we tell you to.
importWorkoutAnchorFromUserDefaultsKeynoneFor an app moving from its own HealthKit sync: the UserDefaults key holding its workout query anchor, so the SDK starts where your code stopped instead of sending every workout again.
AppDelegate.swift
import StrideeHealth

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        HealthSync.configure(HealthSync.Configuration(
            // Your route from step 1, called with your own session.
            tokenProvider: {
                let minted = try await MyAPI.strideeDeviceToken()
                return HealthSync.DeviceToken(token: minted.token, expiresAt: minted.expiresAt)
            }
        ))
        return true
    }
}

4. Connect

When your user taps "Connect Apple Health":

That shows iOS's permission sheet, connects the user (your webhook gets account.connected), and starts syncing — history first, then each new workout and night as HealthKit records it. From then on:

  • disconnect(revokeOnServer: false) stops this device only — on sign-out, say — and leaves the user connected, so their other devices keep syncing. The default disconnects them everywhere: see Disconnecting.
  • connect(requestingAuthorization: false) connects without showing the sheet, for an app that already holds HealthKit access from its own integration. needsAuthorization() tells you beforehand whether connect() would show it.
  • lastError is a HealthSyncError: healthDataUnavailable (no HealthKit on this device), notConfigured, notConnected, disconnectedRemotely (your server disconnected them), tokenUnavailable (your token route failed), rejected (we refused an upload, with our status and message) or transport.

Three things about the phone that shape your UI:

  • iOS never says whether read access was granted. That is deliberate on Apple's part — a denial would itself reveal something about the user's health. See Permissions for what you can know instead, and how to ask well.
  • Health data is unreadable while the phone is locked. A workout that ends with the phone in a pocket syncs after the next unlock, not before.
  • Nothing is lost to an outage. Uploads are queued on disk and retried. If your token route is down, the queue waits for it.
SettingsView.swift
try await HealthSync.shared.connect()
SWIFT
let status = HealthSync.shared.status   // @Published on HealthSync.shared, an ObservableObject
// isConnected, isSyncing, lastSyncDate, lastError, pendingUploads, progress

try await HealthSync.shared.syncNow()
await HealthSync.shared.disconnect()

5. Permissions

Apple shows its permission sheet once per data type, ever. A user who meets it cold tends to switch everything off, and from then on the only way back is the Settings app. So the SDK gives you the pieces to ask well, and to notice when the answer was no.

Explain first, then ask

HealthAccessPrimer is a ready-made SwiftUI screen to show before Apple's sheet: what you read, what it is for, Continue and Not now. Continue connects — which shows Apple's sheet — and onFinished fires once it has.

The words are yours. HealthAccessPrimer.Copy.english is a starting point; pass your own Copy with localized strings — the SDK ships no translations. To do something other than connect on Continue, pass action:.

SettingsView.swift
.sheet(isPresented: $showPrimer) {
    HealthAccessPrimer(
        onFinished: { showPrimer = false },
        onNotNow: { showPrimer = false }
    )
}

Ask without connecting

To ask during onboarding and connect later — or to ask for types you added to dataTypes after users had already answered — call:

iOS only shows the sheet for types never asked about, so calling it again is harmless.

SWIFT
try await HealthSync.shared.requestAuthorization()            // the configured types
try await HealthSync.shared.requestAuthorization(for: [.wellness])

What you can know

status.authorization.unavailable (no HealthKit on this device), .notRequested (the sheet would appear) or .requested (the user has answered for every type). There is no granted: .requested means they answered, not that they said yes. authorizationStatus() asks HealthKit afresh.
status.lastDataFoundWhen a sync last found new data, per data type — [.workouts: Date, .wellness: Date]. The closest thing to a permission check iOS allows.

Put them together for the one case worth handling: connected for a few days, lastDataFound still empty for .workouts, from a user you know trains. That usually means they left the switches off. Tell them so, with a button:

It opens your app's page in Settings — iOS has no link straight to the Health switches. On recent iOS the page lists Health; otherwise the switches are at Settings → Health → Data Access & Devices → your app. Say which in the sentence around the button.

SWIFT
await HealthSync.shared.openSettings()

What you receive

The same events as any provider, with provider: "apple":

EventWhen
account.connectedThe first time the user connects — again only after a disconnect.
activity.createdA workout arrived. data.file.format is fit.
wellness.created / wellness.updatedA summary arrived, or one you have was revised.
account.disconnectedThe user turned syncing off in your app, or you disconnected them.

Workouts

HealthKit has no workout file, so the SDK sends the workout and its samples — heart rate, running and cycling power, cadence, the GPS route — and we encode a FIT file from them. You fetch it from GET /v1/activities/{id}/file like any other recording, so the parser you already run for Garmin reads it.

  • Idempotent on the HealthKit workout's UUID. A workout sent twice is delivered once.
  • Only your account ever receives a given Apple workout. Each app has its own HealthKit grant, so unlike Garmin — where one athlete can be connected to several developers at once — the same workout never fans out to anyone else.

Wellness

Nine of the twelve kinds. The SDK computes daily totals on the phone with HealthKit's statistics queries, which merge iPhone and Apple Watch correctly: summed raw, the steps both of them counted would be counted twice.

kindFrom HealthKitIn metrics
dailyTotals from local midnightsteps, distance_meters, active_kilocalories, bmr_kilocalories, floors_climbed, moderate_intensity_seconds, resting_hr, avg_hr, min_hr, max_hr
sleepSleep stagestotal_sleep_seconds, light_sleep_seconds, deep_sleep_seconds, rem_sleep_seconds, awake_seconds
hrvHRV samples (SDNN, ms)hrv_sdrr — not hrv_avg, see below
fitnessVO₂maxvo2_max
respirationBreaths per minuterespiration_min, respiration_avg, respiration_max
pulse_oxBlood oxygen, percentspo2_min, spo2_avg
body_compositionWeight, body fat, BMIweight_grams, body_fat_percent, body_mass_index
blood_pressureOne cuff readingsystolic, diastolic
skin_temperatureSleeping wrist temperatureskin_temp_celsius

Apple's HRV is SDNN, and it is in hrv_sdrr. Garmin, Zepp and Fitbit report RMSSD in hrv_avg. The two are different statistics with different normal ranges, so we keep them in different fields: a chart that plots hrv_avg shows no Apple data rather than a wrong line. Don't merge them without converting — and there is no exact conversion.

The rest of the mapping:

  • Sleep: Apple's core is light sleep. inBed counts toward nothing. asleepUnspecified — older watches, or sleep tracked by the iPhone alone — counts toward the total but toward no stage, so the stages can add up to less than total_sleep_seconds.
  • moderate_intensity_seconds is Apple's exercise minutes × 60. Apple doesn't split exercise by intensity, so all of it is filed as moderate and none as vigorous.
  • skin_temp_celsius is absolute, not a deviation from baseline: the baseline the Health app shows is not exported. Garmin's deviation is in skin_temp_deviation_celsius.
  • finality is set on every kind, from the item's final. Today's daily arrives tentative and is resent as the day goes on; each change is a wellness.updated.
  • summary is the item as the SDK sent it, camelCase; the samples and stage list behind it are at series_url.
  • Never from Apple: stress, epoch and health_snapshot. HealthKit has nothing they could be built from.

History

On the first sync, every workout HealthKit holds — Apple Watch history routinely goes back years — and the last wellnessHistoryDays of wellness (90 by default). It is uploaded from the phone, so it arrives over the app's first runs and background wake-ups rather than in one go.

Relaying through your own server

If your app already reads HealthKit itself, you can skip the SDK and the device token and send the same JSON from your backend, signed like every other /v1/* call. {id} is the user_id from GET /v1/accounts or a device-token call.

post /v1/accounts/{id}/apple/workouts
Reference

Push a HealthKit workout from your own server.

For apps that read HealthKit themselves and relay through their backend rather than using the SDK's direct upload. Same body and same behavior as POST /v1/device/apple/workouts, signed like the rest of /v1/*.

Relaying connects the user if they are not already — your server sending their workouts is the consent — and sends account.connected the first time.

Path parameters
idstring · uuidRequired

The user's user_id

Body parameters
activityTypeinteger · int32Required

HKWorkoutActivityType's raw value, kept for reference.

durationnumber · doubleRequired

Seconds, as HKWorkout.duration reports it (paused time excluded).

endDatestring · date-timeRequired
serverTypestringRequired

The discipline: RUNNING, CYCLING, SWIMMING, WALKING, HIKING, ROWING, CROSS_COUNTRY_SKIING, ALPINE_SKIING, SNOWBOARDING, PADDLING, ROCK_CLIMBING, TRAINING or GENERIC. Becomes the FIT file's sport.

startDatestring · date-timeRequired
uuidstringRequired

The HKWorkout's UUID. Its identity: sending the same one twice delivers it once.

cyclingCadenceRawSample[]

Revolutions per minute.

Show child attributes
tstring · date-timeRequired

When it was measured.

vnumber · doubleRequired

The value, in the stream's unit.

cyclingPowerRawSample[]

Watts.

Same RawSample shape as above.

deviceNamestring

The recording device's name or model, when HealthKit has one.

formatstring

healthkit.v1. Optional, but anything else is refused.

heartRateRawSample[]

Beats per minute.

Same RawSample shape as above.

routeRawLocation[]

GPS points, HKWorkoutRoute's CLLocations in time order. Empty for an indoor workout.

Show child attributes
latnumber · doubleRequired

Degrees.

lonnumber · doubleRequired

Degrees.

tstring · date-timeRequired
altitudenumber · double

Meters above sea level.

coursenumber · double

Degrees from true north.

horizontalAccuracynumber · double

Meters.

speednumber · double

Meters per second.

verticalAccuracynumber · double

Meters.

runningPowerRawSample[]

Watts.

Same RawSample shape as above.

sourceBundleIdstring

That app's bundle id — com.apple.health for an Apple Watch workout.

sourceNamestring

The app that wrote the workout into HealthKit, by name.

totalDistanceMetersnumber · double
totalEnergyKcalnumber · double

Active energy, kilocalories.

post /v1/accounts/{id}/apple/wellness
Reference

Push Apple Health wellness data from your own server.

The relay counterpart of POST /v1/device/apple/wellness: same body, same behavior, signed. Connects the user if they are not already.

Path parameters
idstring · uuidRequired

The user's user_id

Body parameters
itemsany[]Required

One object per measurement. Every item carries kind, calendarDate and startTime; the rest depends on the kind.

formatstring

healthkit.wellness.v1.

Relaying connects the user if they aren't already — your server sending their data is the consent — and sends account.connected the first time.

You take on what the SDK otherwise does: registering background delivery, querying HealthKit, computing daily totals with statistics queries rather than summing samples, and retrying. Workouts pass through your server, and so does their size.

POST /v1/accounts/{id}/apple/workouts
POST /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6/apple/workouts HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "activityType": 0,
  "cyclingCadence": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "cyclingPower": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "deviceName": null,
  "duration": 0,
  "endDate": "2026-08-05T11:44:02Z",
  "format": null,
  "heartRate": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "route": [
    {
      "altitude": null,
      "course": null,
      "horizontalAccuracy": null,
      "lat": 0,
      "lon": 0,
      "speed": null,
      "t": "2026-08-05T11:44:02Z",
      "verticalAccuracy": null
    }
  ],
  "runningPower": [
    {
      "t": "2026-08-05T11:44:02Z",
      "v": 0
    }
  ],
  "serverType": "string",
  "sourceBundleId": null,
  "sourceName": null,
  "startDate": "2026-08-05T11:44:02Z",
  "totalDistanceMeters": null,
  "totalEnergyKcal": null,
  "uuid": "string"
}
Response · 202
{
  "status": "active",
  "uuid": "string"
}
POST /v1/accounts/{id}/apple/wellness
POST /v1/accounts/4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6/apple/wellness HTTP/1.1
Host: api.stridee.com
Content-Type: application/json
Content-Digest: sha-256=:…:
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:

{
  "format": null,
  "items": [
    null
  ]
}
Response · 202
{
  "items": 0,
  "status": "active"
}

The payload formats

The SDK writes these; you need them only if you relay, or push from the device yourself with a token. Both are camelCase JSON with ISO 8601 dates.

healthkit.v1 — one workout

post /v1/device/apple/workouts
Reference

Push a HealthKit workout from the athlete's device.

The Stridee SDK calls this for every new workout HealthKit reports. The body is the healthkit.v1 JSON: the workout, its sample streams and its route. We turn it into a FIT file and deliver it like any provider's workout — activity.created on your webhook, the file on GET /v1/activities/{id}/file.

Idempotent on the workout's uuid: sending one twice delivers it once. A 409 means the user is not connected (never connected, or disconnected since); a 503 means retry later.

Body parameters
activityTypeinteger · int32Required

HKWorkoutActivityType's raw value, kept for reference.

durationnumber · doubleRequired

Seconds, as HKWorkout.duration reports it (paused time excluded).

endDatestring · date-timeRequired
serverTypestringRequired

The discipline: RUNNING, CYCLING, SWIMMING, WALKING, HIKING, ROWING, CROSS_COUNTRY_SKIING, ALPINE_SKIING, SNOWBOARDING, PADDLING, ROCK_CLIMBING, TRAINING or GENERIC. Becomes the FIT file's sport.

startDatestring · date-timeRequired
uuidstringRequired

The HKWorkout's UUID. Its identity: sending the same one twice delivers it once.

cyclingCadenceRawSample[]

Revolutions per minute.

Show child attributes
tstring · date-timeRequired

When it was measured.

vnumber · doubleRequired

The value, in the stream's unit.

cyclingPowerRawSample[]

Watts.

Same RawSample shape as above.

deviceNamestring

The recording device's name or model, when HealthKit has one.

formatstring

healthkit.v1. Optional, but anything else is refused.

heartRateRawSample[]

Beats per minute.

Same RawSample shape as above.

routeRawLocation[]

GPS points, HKWorkoutRoute's CLLocations in time order. Empty for an indoor workout.

Show child attributes
latnumber · doubleRequired

Degrees.

lonnumber · doubleRequired

Degrees.

tstring · date-timeRequired
altitudenumber · double

Meters above sea level.

coursenumber · double

Degrees from true north.

horizontalAccuracynumber · double

Meters.

speednumber · double

Meters per second.

verticalAccuracynumber · double

Meters.

runningPowerRawSample[]

Watts.

Same RawSample shape as above.

sourceBundleIdstring

That app's bundle id — com.apple.health for an Apple Watch workout.

sourceNamestring

The app that wrote the workout into HealthKit, by name.

totalDistanceMetersnumber · double
totalEnergyKcalnumber · double

Active energy, kilocalories.

Field
uuidThe HKWorkout's UUID. The idempotency key.
activityTypeHKWorkoutActivityType's raw value.
serverTypeThe sport, as RUNNING, CYCLING, SWIMMING, WALKING, HIKING, ROWING, … — what the FIT's sport is set from.
sourceName, sourceBundleId, deviceNameWhich app and device recorded it. Optional.
startDate, endDate, durationDuration in seconds.
totalDistanceMeters, totalEnergyKcalOptional.
heartRate, runningPower, cyclingPower, cyclingCadence[{ t, v }] in bpm, watts, watts and rpm. Empty when the workout has none.
route[{ t, lat, lon, altitude, speed, course, horizontalAccuracy, verticalAccuracy }] — metres, m/s and degrees, as CLLocation reports them.
healthkit.v1
{
  "format": "healthkit.v1",
  "uuid": "5D1E8C1A-2F7B-4E0C-9A43-71C6B0F8D2E4",
  "activityType": 37,
  "serverType": "RUNNING",
  "sourceName": "Apple Watch",
  "sourceBundleId": "com.apple.health.7B3C0E5A-…",
  "deviceName": "Apple Watch",
  "startDate": "2026-10-01T06:02:11Z",
  "endDate": "2026-10-01T06:48:40Z",
  "duration": 2789.0,
  "totalDistanceMeters": 8412.6,
  "totalEnergyKcal": 612.0,
  "heartRate": [{ "t": "2026-10-01T06:02:16Z", "v": 112 }],
  "runningPower": [{ "t": "2026-10-01T06:02:16Z", "v": 246 }],
  "cyclingPower": [],
  "cyclingCadence": [],
  "route": [
    {
      "t": "2026-10-01T06:02:12Z",
      "lat": 47.3769, "lon": 8.5417, "altitude": 408.2,
      "speed": 2.9, "course": 184.0,
      "horizontalAccuracy": 4.1, "verticalAccuracy": 3.0
    }
  ]
}

healthkit.wellness.v1 — a batch of summaries

post /v1/device/apple/wellness
Reference

Push Apple Health wellness data from the athlete's device.

A batch of healthkit.wellness.v1 items — daily totals, sleep, HRV, VO₂max, respiration, blood oxygen, body composition, blood pressure and wrist temperature. The Stridee SDK assembles these from HealthKit and calls this for you. Each item is stored like any provider's wellness summary and announced on your webhook.

An item's kind and startTime are its identity: sending it again replaces it, which is how a day still in progress stays current. Mark those "final": false.

Body parameters
itemsany[]Required

One object per measurement. Every item carries kind, calendarDate and startTime; the rest depends on the kind.

formatstring

healthkit.wellness.v1.

Every item carries:

Field
kindOne of the nine above. Anything else refuses the batch.
calendarDateYYYY-MM-DD, the user's local day.
startTimeWith kind, the item's identity: an item sent again with the same pair replaces the first, and emits wellness.updated only if something changed. A daily item starts at local midnight.
endTimeOptional. Not before startTime.
tzOffsetSecondsThe user's UTC offset on that day.
finalfalse while the value can still change — stored as tentative.
idOptional: the HealthKit sample's UUID, when the item is one sample. Recorded, not used to deduplicate.

And then the kind's own fields:

kindFields
dailysteps, distanceMeters, activeKilocalories, basalKilocalories, flightsClimbed, exerciseMinutes, restingHeartRate, avgHeartRate, minHeartRate, maxHeartRate
sleepstages: [{ stage, start, end }], stage one of core, deep, rem, awake, inBed, asleepUnspecified
hrvsamples: [{ t, v }], SDNN in ms
fitnessvo2Max
respirationsamples: [{ t, v }], breaths per minute
pulse_oxsamples: [{ t, v }], percent, 0–100
body_compositionweightKg, bodyFatPercent (0–100), bodyMassIndex
blood_pressuresystolic, diastolic
skin_temperaturecelsius
healthkit.wellness.v1
{
  "format": "healthkit.wellness.v1",
  "items": [
    {
      "kind": "daily",
      "calendarDate": "2026-10-01",
      "startTime": "2026-09-30T22:00:00Z",
      "endTime": "2026-10-01T22:00:00Z",
      "tzOffsetSeconds": 7200,
      "final": true,
      "steps": 8123,
      "activeKilocalories": 512.5,
      "restingHeartRate": 52
    },
    {
      "kind": "sleep",
      "calendarDate": "2026-10-02",
      "startTime": "2026-10-01T20:30:00Z",
      "endTime": "2026-10-02T04:10:00Z",
      "tzOffsetSeconds": 7200,
      "final": true,
      "stages": [
        { "stage": "core", "start": "2026-10-01T20:41:00Z", "end": "2026-10-01T22:05:00Z" },
        { "stage": "deep", "start": "2026-10-01T22:05:00Z", "end": "2026-10-01T22:58:00Z" }
      ]
    },
    {
      "kind": "hrv",
      "calendarDate": "2026-10-02",
      "startTime": "2026-10-02T01:00:00Z",
      "final": true,
      "samples": [{ "t": "2026-10-02T01:00:00Z", "v": 48.2 }]
    }
  ]
}

Limits

Workout body30 MB — a 413 over it.
Wellness body10 MB and 2,000 items. The SDK pages above that.
A bad wellness itemRefuses the whole batch with a 400 naming it, so nothing is half-stored:

A 409 on a device push means the user is not connected — never connected, or disconnected since. A 503 means we could not store it right now; retry it, which the SDK does on its own.

Text
items[12]: `stress` is not a kind Apple Health sends

Disconnecting

From the app, HealthSync.shared.disconnect(). From your server, DELETE /v1/connections/{id}. Either way the connection is revoked, every device token for that user stops working, and you get account.disconnected. Data already delivered stays yours.

What it cannot do is take back iOS's permission: that grant is between the user and your app, and only they can turn it off in Settings. Connecting again later is another connect().

ToCall
Check from the device whether the user is connectedGET /v1/device/connections/{provider}
Connect from the devicePUT /v1/device/connections/{provider}
Disconnect from the deviceDELETE /v1/device/connections/{provider}

POST /v1/connect with provider: "apple" is a 400 pointing here: there is no consent page to send anyone to.

Shipping it to the App Store

Apple reviews health apps closely, and these are the parts that touch this integration:

  • Guideline 5.1.3. Health data may not be used for advertising, marketing or data mining, and may not be sold or shared with third parties for those purposes. Using it to run your product is fine; feeding it to an ad network is not.
  • Say where it goes. Your privacy policy and your App Store privacy labels must disclose that health and fitness data leaves the device for your backend and your processors — which includes us.
  • Explain the permission. Call connect() from a screen that says why, not at first launch. A permission sheet with no context is one users decline — and since iOS won't tell you they did, it looks exactly like a user with no data.

Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.