Format reference
This page is for developers and self-hosters. It describes what the app sends to a destination you configure, so you can build your own receiver. Every example below is taken verbatim from the app's own tests.
Looking for the file export instead (CSV, JSON, NDJSON to download)? That's further down. To set up a destination in the first place, see Set up a destination.
The HTTP interface
One sync is one POST with a JSON body. No chunking, no query parameters, no multipart, no compression.
POST https://your-server.example/health
Content-Type: application/json
X-Sender: export-health-data
{ "data": { "metrics": [ … ], "workouts": [] } }
https:// only. The app refuses unencrypted addresses outright.
X-Sender is always export-health-data and stays that way across releases. If several programs post to the same address, that's how you tell us apart.
Your access key — if you set one — is sent as a header you name yourself, and it is set last. Name your field X-Sender and yours wins.
Delivered means 2xx. Any other status counts as a failed attempt. If your address answers with a redirect (301, 302, 303, 307, 308), that counts as an error, not as a delivery: the app does not follow the redirect and writes the 3xx code to its log. Answer 200 once the body is safely stored; the content of your reply doesn't matter. Its first 200 characters land in the app's log, which makes a short receipt worthwhile.
Timeout: 90 seconds. After that the app gives up and records "no answer". So store the body first, then answer.
Retries: up to six attempts, with base delays of 60, 300, 900, 3600 and 21,600 seconds, each jittered to between half and the full value. Retry-After is not honoured.
Size: 3.5 to 5 kB per delivery in practice.
The envelope
Always the same three keys. workouts is always an empty array — the app sends no workouts; the field exists for compatibility only.
{ "data": { "metrics": [ … ], "workouts": [] } }
An empty run goes out too. It is not an error but a sign of life:
{"data":{"metrics":[],"workouts":[]}}
Every element of metrics has exactly three keys, in a fixed order:
{ "name": "…", "units": "…", "data": [ … ] }
The order of metrics is predictable: quantity metrics first, sorted alphabetically by name, then heart_rate, then sleep_analysis. Within a metric, points ascend by day.
The twelve families
Automatic delivery carries twelve families — not the 188 data types of the file export. Careful: three wire names differ from the obvious ones. Trust this column, not the Apple naming.
name units step_count count resting_heart_rate bpm heart_rate_variability ms ← not …_sdnn vo2_max ml/kg/min weight_body_mass kg ← not body_mass body_fat_percentage % lean_body_mass kg respiratory_rate brpm blood_oxygen_saturation % ← not oxygen_saturation apple_sleeping_wrist_temperature celsius heart_rate bpm sleep_analysis hr
A family appears only when there is something to report. Don't expect all twelve in every delivery.
The three kinds of data points
A data point looks different depending on the family. Implement only the first kind and you lose heart rate and sleep.
1 · Quantity metric
All ten quantity metrics, one point per day.
{"date":"2026-08-02 12:00:00 +0200","qty":97.4,"source":"healthpipe","sources":["com.apple.health.<ID>"]}
sources (from version 1.0.1) lists the bundle ids of every source that contributed to the day's value — sorted, deduplicated. The key is always present: [] means the app read the day but no source had written data for it — an empty family is not an error. source stays healthpipe. Apple sources carry a random identifier of their own per device — which of them is the watch is not stated in it. Only the older version 1.0.0 does not send sources — the key has been included since the 1.0.1 update (7 September 2026).
2 · heart_rate
No qty. Three capitalised fields instead — the day's minimum, average and maximum.
{"date":"2026-08-02 12:00:00 +0200","Min":44,"Avg":61.5,"Max":142,"source":"healthpipe","sources":["com.apple.health.<ID>","com.whoop.iphone"]}
sources as for quantity metrics — here, the sources the day's minimum, average and maximum were built from.
3 · sleep_analysis
One night per local day. Here date is not the noon stamp but identical to sleepEnd — a night belongs to the day it ended on.
{"date":"2026-08-02 06:40:00 +0200",
"sleepStart":"2026-08-01 23:10:00 +0200",
"sleepEnd":"2026-08-02 06:40:00 +0200",
"asleep":7.1667,"totalSleep":7.1667,
"deep":0.8333,"rem":2,"core":4.3333,"awake":0.3333,
"source":"Apple Watch von Harald",
"candidates":["com.apple.health.<ID>"]}
A missing field is not zero. If the chosen source has no real sleep stages — an iPhone's motion detection, for instance — then deep, rem and core are absent entirely. awake appears only when it is greater than zero. Storing a missing field as 0 invents data.
asleep and totalSleep always carry the same value. Take one of them, not two series.
candidates (from version 1.0.1) lists the bundle ids of every source that had a candidate session for the night — sorted, deduplicated, including the sources not chosen. source still names the chosen source. For example, if an Oura ring was chosen for a night that WHOOP also recorded, you get:
"source":"Oura","candidates":["com.ouraring.oura","com.whoop.iphone"]
Only the older version 1.0.0 does not send candidates — the key has been included since the 1.0.1 update (7 September 2026).
Further keys
Points are open objects and may carry further keys. sources and candidates arrived with 1.0.1, each at the end of the point after source — everything before them is unchanged. Read points by named key and skip what you don't know. A receiver that fails on an unknown key does not follow the specification.
Time, numbers, units
The timestamp is not ISO 8601. The offset carries no colon:
yyyy-MM-dd HH:mm:ss ±HHMM 2026-08-02 12:00:00 +0200
Strict ISO parsers choke on it. In PHP use 'Y-m-d H:i:s O', in Python '%Y-%m-%d %H:%M:%S %z'.
Daily points are stamped 12:00 local time, not midnight. That is deliberate: derive the calendar day from the timestamp and a daylight-saving change can never shift it — noon is twelve hours from either edge of the day. The offset itself is likewise determined at noon of that day, so days on either side of a change carry their own.
Numbers carry at most four decimal places, with trailing zeros stripped. That's why the example above reads "rem":2 and not 2.0000. Treat every numeric field as a float.
Percentages are already sent as 0–100 with unit % — 97.4 means 97.4 %. Do not multiply again.
Sleep values are sent in hours (unit hr), not seconds or minutes.
The source field is the constant healthpipe for quantity metrics and heart_rate — a frozen identifier from the original format, not a device name. Only sleep points carry the real name of the device that recorded the night, and that can change from night to night. Expect personal data in that field. The lists sources and candidates, by contrast, carry bundle ids only (com.ouraring.oura, com.whoop.iphone, com.apple.health.…), never device names.
What your receiver has to handle
Delivery is at-least-once, not exactly-once. Two reasons: the read windows overlap — the app includes the last three days so gaps close by themselves — and an attempt whose reply is lost gets repeated. So the same day arrives repeatedly, with the same or a corrected value.
One exception to that window. heart_rate and step_count carry by far the most raw samples, so every run you did not start yourself — an automation included — reads them one day back. Those two get the full three days only when someone taps "Jetzt senden" (send now) in the app. A gap in heart rate or steps older than a day therefore does not close by itself; every other family still fetches three days.
Store by updating, not by appending: key on metric name, day and, where it applies, the kind of statistic, and overwrite on the second arrival. Blind inserts leave you with every day three or four times over after a week.
Unchanged deliveries are skipped. If the content is byte-identical to the last acknowledged one, the app saves the round trip — unless you tap "send now", which always goes out. Silence is therefore not necessarily a fault.
Answer quickly, and write before you answer. The timeout is 90 seconds; past it the same delivery comes back later.
File export
The "Export" tab produces a file rather than a delivery — different rules, different format.
CSV (the "Export" tab, button "Tabelle erstellen"): four columns, Day,Metric,Value,Unit, one row per day and metric. UTF-8 with BOM, CRLF line endings. Separator and decimal mark follow the regional setting — EU means semicolon and comma, US means comma and point. Details, and the Excel traps, are in Open your spreadsheet in Excel & Numbers.
JSON and NDJSON (advanced export only): raw samples instead of daily values, all 188 data types selectable. They ignore the regional setting entirely — always a decimal point, always ISO timestamps with a colon in the offset. Careful: percentages appear there as fractions (0.974, unit pct), not as 97.4 as in delivery. NDJSON is one object per line, without a BOM; JSON is one document that begins:
{"schema":"healthpipe.samples.v1", …}
What this page does not promise
The delivery format is frozen because existing receivers read it — it will not change without good reason. That is not a formal guarantee: if a family is added, it will appear in the table above one day; if a key is added, it will appear under its kind of data point. Build your receiver so an unknown name — or an unknown key inside a point — is skipped rather than fatal.
And: the app cannot see whether your destination actually stored anything after the 2xx. "Delivered" means only that your server confirmed receipt.