Format reference

What goes over the wire · version 1.0.0 · September 2026

This page describes what the app sends to a destination you configure — complete enough to build a receiver without guessing. Every example below is taken verbatim from the app's own tests; if the format changes, those tests break before this page goes stale.

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.

Note: the app's interface is German only. This reference is not — the wire format has no language.

The HTTP contract

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, 3xx included, because the app judges the code it ends up seeing. 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.

Ninety seconds. After that the app gives up and records "no answer". So write first, answer second — not the other way round.

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: 2 to 3 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 point shapes

A data point looks different depending on the family. Implement only the first shape and you lose heart rate and sleep.

1 · Quantity metric

Nine of the ten quantity metrics, one point per day.

{"date":"2026-08-02 12:00:00 +0200","qty":97.4,"source":"healthpipe"}

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"}

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"}

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.

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 already travel as 0–100 with unit %97.4 means 97.4 %. Do not multiply again.

Sleep values travel 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.

What your receiver has to handle

Delivery is at-least-once, not exactly-once. Two reasons: the read window heals — 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.

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. Ninety seconds is the limit; 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. JSON is one document beginning {"schema":"healthpipe.samples.v1", …}; NDJSON is one object per line, without a BOM.

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. Build your receiver so an unknown name 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.

← Back to the start page