Apple Health
NewApple 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.
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.created1. 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.
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.
external_user_idstringRequiredYour 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_idis 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 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"
}{
"expires_at": "2026-08-05T11:44:02Z",
"token": "string",
"user_id": "4c9a7e15-6d3b-42f8-91c0-8e5b2a7d04f6"
}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 capability | Signing & Capabilities → + Capability → HealthKit. |
| Background Delivery | Tick it under the HealthKit capability. It adds com.apple.developer.healthkit.background-delivery, which lets iOS wake your app when a new workout lands. |
NSHealthShareUsageDescription | The sentence on iOS's permission sheet. Without it, asking for access crashes the app. The SDK only reads, so you don't need NSHealthUpdateUsageDescription. |
https://github.com/stridee-fit/stridee-health-ios<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/><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:
| Setting | Default | |
|---|---|---|
dataTypes | [.workouts, .wellness] | Drop one to sync only the other. |
excludedSourceBundleIDs | Garmin Connect and Wahoo | Workouts 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. |
wellnessHistoryDays | 90 | How far back wellness goes on the first sync. |
baseURL | https://api.stridee.com | Change it only if we tell you to. |
importWorkoutAnchorFromUserDefaultsKey | none | For 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. |
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 whetherconnect()would show it.lastErroris aHealthSyncError: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) ortransport.
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.
try await HealthSync.shared.connect()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:.
.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.
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.lastDataFound | When 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.
await HealthSync.shared.openSettings()What you receive
The same events as any provider, with provider: "apple":
| Event | When |
|---|---|
account.connected | The first time the user connects — again only after a disconnect. |
activity.created | A workout arrived. data.file.format is fit. |
wellness.created / wellness.updated | A summary arrived, or one you have was revised. |
account.disconnected | The 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.
kind | From HealthKit | In metrics |
|---|---|---|
daily | Totals from local midnight | steps, distance_meters, active_kilocalories, bmr_kilocalories, floors_climbed, moderate_intensity_seconds, resting_hr, avg_hr, min_hr, max_hr |
sleep | Sleep stages | total_sleep_seconds, light_sleep_seconds, deep_sleep_seconds, rem_sleep_seconds, awake_seconds |
hrv | HRV samples (SDNN, ms) | hrv_sdrr — not hrv_avg, see below |
fitness | VO₂max | vo2_max |
respiration | Breaths per minute | respiration_min, respiration_avg, respiration_max |
pulse_ox | Blood oxygen, percent | spo2_min, spo2_avg |
body_composition | Weight, body fat, BMI | weight_grams, body_fat_percent, body_mass_index |
blood_pressure | One cuff reading | systolic, diastolic |
skin_temperature | Sleeping wrist temperature | skin_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
coreis light sleep.inBedcounts 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 thantotal_sleep_seconds. moderate_intensity_secondsis 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_celsiusis absolute, not a deviation from baseline: the baseline the Health app shows is not exported. Garmin's deviation is inskin_temp_deviation_celsius.finalityis set on every kind, from the item'sfinal. Today'sdailyarrivestentativeand is resent as the day goes on; each change is awellness.updated.summaryis the item as the SDK sent it, camelCase; the samples and stage list behind it are atseries_url.- Never from Apple:
stress,epochandhealth_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.
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.
idstring · uuidRequiredThe user's user_id
activityTypeinteger · int32RequiredHKWorkoutActivityType's raw value, kept for reference.
durationnumber · doubleRequiredSeconds, as HKWorkout.duration reports it (paused time excluded).
endDatestring · date-timeRequiredserverTypestringRequiredThe 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-timeRequireduuidstringRequiredThe HKWorkout's UUID. Its identity: sending the same one twice
delivers it once.
cyclingCadenceRawSample[]Revolutions per minute.
Show child attributesHide child attributes
tstring · date-timeRequiredWhen it was measured.
vnumber · doubleRequiredThe value, in the stream's unit.
cyclingPowerRawSample[]Watts.
Same RawSample shape as above.
deviceNamestringThe recording device's name or model, when HealthKit has one.
formatstringhealthkit.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 attributesHide child attributes
latnumber · doubleRequiredDegrees.
lonnumber · doubleRequiredDegrees.
tstring · date-timeRequiredaltitudenumber · doubleMeters above sea level.
coursenumber · doubleDegrees from true north.
horizontalAccuracynumber · doubleMeters.
speednumber · doubleMeters per second.
verticalAccuracynumber · doubleMeters.
runningPowerRawSample[]Watts.
Same RawSample shape as above.
sourceBundleIdstringThat app's bundle id — com.apple.health for an Apple Watch workout.
sourceNamestringThe app that wrote the workout into HealthKit, by name.
totalDistanceMetersnumber · doubletotalEnergyKcalnumber · doubleActive energy, kilocalories.
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.
idstring · uuidRequiredThe user's user_id
itemsany[]RequiredOne object per measurement. Every item carries kind, calendarDate
and startTime; the rest depends on the kind.
formatstringhealthkit.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/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"
}{
"status": "active",
"uuid": "string"
}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
]
}{
"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
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.
activityTypeinteger · int32RequiredHKWorkoutActivityType's raw value, kept for reference.
durationnumber · doubleRequiredSeconds, as HKWorkout.duration reports it (paused time excluded).
endDatestring · date-timeRequiredserverTypestringRequiredThe 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-timeRequireduuidstringRequiredThe HKWorkout's UUID. Its identity: sending the same one twice
delivers it once.
cyclingCadenceRawSample[]Revolutions per minute.
Show child attributesHide child attributes
tstring · date-timeRequiredWhen it was measured.
vnumber · doubleRequiredThe value, in the stream's unit.
cyclingPowerRawSample[]Watts.
Same RawSample shape as above.
deviceNamestringThe recording device's name or model, when HealthKit has one.
formatstringhealthkit.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 attributesHide child attributes
latnumber · doubleRequiredDegrees.
lonnumber · doubleRequiredDegrees.
tstring · date-timeRequiredaltitudenumber · doubleMeters above sea level.
coursenumber · doubleDegrees from true north.
horizontalAccuracynumber · doubleMeters.
speednumber · doubleMeters per second.
verticalAccuracynumber · doubleMeters.
runningPowerRawSample[]Watts.
Same RawSample shape as above.
sourceBundleIdstringThat app's bundle id — com.apple.health for an Apple Watch workout.
sourceNamestringThe app that wrote the workout into HealthKit, by name.
totalDistanceMetersnumber · doubletotalEnergyKcalnumber · doubleActive energy, kilocalories.
| Field | |
|---|---|
uuid | The HKWorkout's UUID. The idempotency key. |
activityType | HKWorkoutActivityType's raw value. |
serverType | The sport, as RUNNING, CYCLING, SWIMMING, WALKING, HIKING, ROWING, … — what the FIT's sport is set from. |
sourceName, sourceBundleId, deviceName | Which app and device recorded it. Optional. |
startDate, endDate, duration | Duration in seconds. |
totalDistanceMeters, totalEnergyKcal | Optional. |
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. |
{
"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
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.
itemsany[]RequiredOne object per measurement. Every item carries kind, calendarDate
and startTime; the rest depends on the kind.
formatstringhealthkit.wellness.v1.
Every item carries:
| Field | |
|---|---|
kind | One of the nine above. Anything else refuses the batch. |
calendarDate | YYYY-MM-DD, the user's local day. |
startTime | With 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. |
endTime | Optional. Not before startTime. |
tzOffsetSeconds | The user's UTC offset on that day. |
final | false while the value can still change — stored as tentative. |
id | Optional: the HealthKit sample's UUID, when the item is one sample. Recorded, not used to deduplicate. |
And then the kind's own fields:
kind | Fields |
|---|---|
daily | steps, distanceMeters, activeKilocalories, basalKilocalories, flightsClimbed, exerciseMinutes, restingHeartRate, avgHeartRate, minHeartRate, maxHeartRate |
sleep | stages: [{ stage, start, end }], stage one of core, deep, rem, awake, inBed, asleepUnspecified |
hrv | samples: [{ t, v }], SDNN in ms |
fitness | vo2Max |
respiration | samples: [{ t, v }], breaths per minute |
pulse_ox | samples: [{ t, v }], percent, 0–100 |
body_composition | weightKg, bodyFatPercent (0–100), bodyMassIndex |
blood_pressure | systolic, diastolic |
skin_temperature | celsius |
{
"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 body | 30 MB — a 413 over it. |
| Wellness body | 10 MB and 2,000 items. The SDK pages above that. |
| A bad wellness item | Refuses 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.
items[12]: `stress` is not a kind Apple Health sendsDisconnecting
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().
| To | Call |
|---|---|
| Check from the device whether the user is connected | GET /v1/device/connections/{provider} |
| Connect from the device | PUT /v1/device/connections/{provider} |
| Disconnect from the device | DELETE /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.