# healthd: the complete reference, for language models > An iPhone app that sends your Apple Health samples to your own HTTP endpoint, as JSON or CSV, retries failed deliveries, and keeps a delivery log you can read without a terminal. This file is self-contained: it is everything you need to build a receiver and a dashboard for a user of healthd, and to know what the app can and cannot do. It is generated when the site is built, from the same pages and tested example files as the human documentation, so the two cannot disagree. It describes version 1 of the payload (`schemaVersion` `1`). Start with the first two sections. The rest is the reference: every key, type and unit, the CSV layout, delivery semantics and limits. ## Contents 1. Overview 2. What the app can and cannot do 3. Build a receiver and dashboard for this user 4. Getting started 5. Receiver setup 6. JSON payload 7. CSV 8. Shortcuts 9. Delivery semantics 10. Limits ## Overview The app reads the Apple Health samples you choose on your iPhone and sends them, as HTTP requests, to an endpoint you run. There is no server in this product: your endpoint is the only place the data goes. These pages are what you need to write that endpoint, run it, and know what to expect from it. ### If you read nothing else 1. **Deduplicate on `uuid`.** Upsert on it — never on `id`, which repeats by design. See "Delivery semantics". 2. **Ignore keys and columns you do not recognise.** New ones may appear inside version 1; none will be removed or renamed. 3. **Answer with any `2xx`.** The response body is never read, and a redirect is never followed. 4. **A batch is at most 1000 samples and stays under 1 MB**, but one export can be many batches. ## What the app can and cannot do Read this before promising the user anything. Each line below is a fact about version 1 of the app, not a design choice for your code. ### CAN - Send Apple Health samples from the user's iPhone straight to one HTTP endpoint the user runs, as JSON or as CSV. There is no server in between: the user's endpoint is the only place the data goes. - Deliver 24 sample types in six groups — `activity`, `heart`, `body`, `sleep`, `vitals`, `workouts`. The user turns groups on one by one, so expect any subset. - Export on a tap (Export Now), from the Shortcuts action "Export Health Data", or unattended through the scheduled export. - Export a fixed range ending now from a tap or a Shortcut: last 3 hours, 24 hours, 7 days or 30 days. - Resume the scheduled export from a stored position, so it does not re-send what it already delivered, across app kills, restarts and lost networks. - Retry a failed batch with backoff — 15 minutes, doubling to a ceiling of one hour — for up to seven days, as the same bytes under the same `exportId` and `batchSequence`. - Publish the key to deduplicate on: every sample carries Apple Health's own `uuid`. - Send a credential in a request header whose name the user chooses, such as `Authorization`. - Deliver to plain HTTP at an IP address on the user's own network. - Check the endpoint with "Test connection", a probe with no samples in it. - Keep every batch at or under 1000 samples and under 1 MB. - Show the user, inside the app, a log of the last 100 runs, and notify them after a number of consecutive failed deliveries they choose. Your receiver sees none of this. - Run on iOS 17 and later. ### CANNOT - Deliver in real time. The scheduled export aims for data typically within an hour of it reaching Health; iOS decides when the app wakes. It is a target, not a schedule, and nothing is promised in seconds or minutes. Never label a dashboard built on it "live" or "real-time". - Keep delivering in Low Power Mode, or after the user swipes the app away in the app switcher. Queued work survives a swipe-away, but nothing is sent until the app is opened again. - Read Health while the phone is locked. A locked phone can still send batches that were already queued; new samples wait for an unlock. - Tell your receiver about deletions. A sample deleted in Health after delivery stays in your copy. - Signal coalescing. When Health replaces fine-grained historic samples with one combined sample, you hold both, so a sum over that span can count twice. - Send workout routes (GPS traces). Heart rate during a workout arrives as ordinary `heart` samples. - Export a custom date range. The four fixed ranges are the only ones. - Retry forever. Work pending for more than seven days is dropped, and a batch answered with `400`, `401`, `403`, `404`, `405`, `422`, a redirect or a bare `413` is dropped at once. Only a manual export over the lost range brings it back. - Read your response. The app records the status code only, and never follows a redirect, so a receiver cannot send settings, commands or data back to the phone. - Be queried. The phone pushes; there is no API your receiver can call to ask for data, and no server or account of ours in the middle. - Store health data in iCloud. - Show charts or a dashboard in the app. That is what you are building. - Export anything but Apple Health: no Screen Time, Messages, Mail, Notes, Calendar or other data. - Run anywhere but the iPhone: there is no Mac, Apple Watch or Vision Pro app. - Deliver any other way than a direct HTTP request to the user's endpoint: no email, no file sync, no message broker. - Run two exports at once. A second export that starts while one is running is refused, not queued. ## Build a receiver and dashboard for this user The user runs the app on their iPhone and wants to see their own data. Your job is two parts: a **receiver** that stores what the app sends, and a **dashboard** that reads the store. Everything you need is in this file; the reference sections further down have every key, type and unit. ### 1. Receive - Accept `POST` on the path the user will type into the app. The app sends to that address exactly and adds no path or query. - **Credential.** The user picks a header name and a secret in the app. Make both configurable on your side, defaulting to `Authorization: Bearer `. Compare the whole header value. A wrong credential answered with `401` is **not retried**: that batch is lost until the user exports its range again, so say so in your setup notes. - Pick the parser by `Content-Type`: `application/json` or `text/csv; charset=utf-8`. - **Probe.** JSON with `connectivityTest` `true`, or CSV with a header row and no data rows: answer `200` and store nothing. - **Version.** `schemaVersion` must be `1` (a JSON key; in CSV a column on every row). Anything else: answer `400` and store nothing. - **Status codes.** Answer `2xx` once the batch is stored. Answer `400` only for a body that can never be accepted; answer `500` for your own trouble (disk, database), so the app keeps the batch and retries it. The response body is never read. - **CSV.** Parse with a real CSV parser, by header name, never by position. Ignore columns you do not know. An empty cell is an absent key, never `0`. Build the same sample object the JSON carries: `value` is a float for `kind` `quantity` and an integer for `kind` `category`; `activityType` is an integer; `durationSeconds`, `distanceMeters` and `activeEnergyKilocalories` are numbers. ### 2. Store - One row per sample, **primary key `uuid`**, and **upsert** on it: a `uuid` you already hold is the same sample again — count it as a duplicate, never as an error. Never deduplicate on `id`; distinct samples share it by design. - Store `uuid`, `type`, `group`, `source`, `kind`, `start`, `end`, the numeric fields, and the whole sample object as JSON, so keys added later are kept. - Store each batch in **one transaction**, so a retried batch never meets half of itself. - Optionally keep one row per accepted request (`exportId`, `batchSequence`, `isFinalBatch`, received time, new and duplicate counts). It is what "last received" on the dashboard reads. - The example receiver in the reference section does all of the above; you can start from it. ### 3. Aggregate per day **Which day.** Convert `start` to the user's time zone and take the calendar date. Ask the user for their time zone (an IANA name); if you cannot, use the UTC offset written in the timestamp itself. A sample belongs to one day, the day its `start` falls on — except sleep, which belongs to the day its `end` falls on (the morning of waking). **Per metric.** Take the rule from `type`. Quantity types below are written without their `HKQuantityTypeIdentifier` prefix. | Types | Day value | Per-source rule | | --- | --- | --- | | `StepCount`, `DistanceWalkingRunning`, `FlightsClimbed`, `ActiveEnergyBurned`, `BasalEnergyBurned`, `AppleExerciseTime`, `AppleStandTime` | Sum of `value` | **Yes** | | `HKCategoryTypeIdentifierSleepAnalysis` | Minutes asleep: sum of `end` − `start` over samples whose `value` is `1`, `3`, `4` or `5` | **Yes** | | `HeartRate`, `RestingHeartRate`, `WalkingHeartRateAverage`, `HeartRateVariabilitySDNN`, `OxygenSaturation`, `RespiratoryRate`, `VO2Max`, `BodyTemperature`, `BloodPressureSystolic`, `BloodPressureDiastolic` | Minimum, mean and maximum of `value`, and the sample count | No: each sample is a reading | | `BodyMass`, `Height`, `BodyMassIndex`, `BodyFatPercentage`, `LeanBodyMass` | The latest sample of the day, by `start` | No | | `HKWorkoutTypeIdentifier` (`kind` `workout`) | Count, total `durationSeconds`, and totals of `distanceMeters` and `activeEnergyKilocalories` over the workouts that carry them | No: list each workout | **The per-source rule — never sum two devices.** A phone in a pocket and a watch on the wrist both count the same walk, and both write step samples to Health. The payload carries both, each with its own `source`. Adding them up double-counts, and the total looks plausible, so nobody notices. For every type marked **Yes**: 1. Per day, per type, total each `source` separately. 2. The day's value is the **one** source with the largest total. On a tie, take the source that sorts first, so the answer is stable. 3. Show which source won, and keep the others visible as alternatives. Picking per day, rather than always the same device, follows whichever device was actually worn or carried that day. If the user asks for a fixed priority, let them configure one. Averaging a reading type across sources, as in the table, is fine. **Units and values.** Use the `unit` sent with each sample. Blood oxygen and body fat are a `fraction` (`0.97`): multiply by 100 for a percentage. Heart rate is `count/min`. Sleep `value` `0` is in bed and `2` is awake: neither is sleep. A category value with no `name` is a stage this build does not know: store it, count it nowhere, and show it as "unknown". Name a workout by `activityName`, or "activity type N" when it has none. **Honest numbers.** - A day with no samples is "no data", not `0`. - Today is not over. Mark it partial and leave it out of averages. - Keep samples of types you do not know. Show them as a count; do not fail. - Two different workouts can overlap if two apps recorded the same session. Show both; do not merge them silently. ### 4. Show freshness, not "live" - Show when the last batch arrived, and the newest sample's `end`. Never label the page "live" or "real-time". - When nothing has arrived for several hours, say so, and name the usual causes: Low Power Mode, the app swiped away, a locked phone, the receiver unreachable, or a credential that was rejected. - To fill in history, the user runs a manual export; the longest range is 30 days. Overlap is harmless, because you upsert on `uuid`. ### 5. Check it before you hand it over - The same batch sent twice: the second answer reports only duplicates, and every day total is unchanged. - The same samples as JSON and then as CSV: no new rows. - A CSV with a quoted comma, a quote and a newline inside `source`, empty cells, and an extra column at the end: parsed, with the empty cells absent. - A body with `schemaVersion` `2`: `400`, nothing stored. - Two samples with the same `id` and different `uuid`s: both stored. - A step count written by two sources on one day: the day shows one source's total, not the sum. A sketch of the per-source rule in SQL, for a table with one row per sample and a `day` already computed in the user's time zone: Illustrative, not executed: ```sql WITH per_source AS ( SELECT day, type, source, SUM(value) AS total FROM samples WHERE type IN ('HKQuantityTypeIdentifierStepCount', 'HKQuantityTypeIdentifierActiveEnergyBurned') GROUP BY day, type, source ), ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY day, type ORDER BY total DESC, source ASC) AS rank FROM per_source ) SELECT day, type, source AS winning_source, total FROM ranked WHERE rank = 1; ``` ## Getting started ### What you need - An iPhone on iOS 17 or later, with the app installed. - An HTTP endpoint the phone can reach that answers `2xx`. A receiver on your own network can be plain HTTP at an IP address. ### 1. Run a receiver The quickest correct one is an example receiver (see "Receiver setup"): one file, no dependencies, samples into SQLite. With Node 22.13 or later, from `examples/receiver-node`, run `HOST=0.0.0.0 node server.mjs`; with Python 3.11 or later, from `examples/receiver-python`, run `HOST=0.0.0.0 python3 receiver.py`. Both listen on port 8080. `HOST=0.0.0.0` makes them reachable from the phone rather than only from the computer they run on. ### 2. Point the app at it In the app, open **Destination** and enter: - **Address** — the full address of your receiver: scheme (`http` or `https`), host, port and path. The app sends to it exactly, and appends no path and no query. - **Method** — `POST` is the usual choice. - **Headers** — any your receiver needs, plus a credential in the header name you choose. The example receivers check `Authorization: Bearer ` when started with `RECEIVER_TOKEN` set. - **Format** — JSON or CSV. Same samples either way; see "JSON payload" and "CSV". Then tap **Test connection**. The app sends a probe — the same request with no samples in it — over the same method, headers and credential a real export uses. The example receivers answer `200` and record nothing. ### 3. Choose what leaves the phone Open **Metrics** and turn on the groups you want: activity, heart, body, sleep, vitals, workouts. Health asks for read access per group, when you turn that group on. Only enabled groups are read. ### 4. Export Tap **Export Now** and pick a time range. If your receiver is on your local network, the first export is where the app explains why it needs to reach devices on your network and iOS asks for that permission; answer it once and it is remembered. Your receiver now holds the samples. The example receivers report counts at `GET /stats`. ### Next - Run exports without tapping: arm the scheduled export in the app, or build a Shortcuts automation — see "Shortcuts". Read "Limits" first: the schedule is a target, not a promise. - Before you write your own receiver, read "Delivery semantics". The one rule that matters most is to upsert on `uuid`. ## Receiver setup A receiver is anything that accepts an HTTP request and answers `2xx`. What follows is what the app sends and expects, then two small receivers that do all of it and are tested against synthetic batches on every change. ### What the app sends and expects | What | Behaviour | | --- | --- | | Method and address | Whatever the user configured, exactly. No path or query is appended. | | `Content-Type` | `application/json` for a JSON destination; `text/csv; charset=utf-8` for CSV. Set after the user's own headers, so it cannot be mislabelled. | | Credential | In the header name the user chose, applied last, so it wins over a same-named header typed by hand. | | Body | One JSON object or one CSV document, UTF-8, no BOM. Not compressed, not chunked, not multipart. | | Size | At most 1000 samples, under 1 MB — inside nginx's default `client_max_body_size`. | | Concurrency | One request at a time. The next batch is not built until the previous one is answered. | | Timeout | 60 seconds on a batch's first attempt, 15 seconds on a retry. A slow answer is not lost: the batch stays queued. | | Success | Any status from `200` to `299`. | | Response body | Never read. Only the status code is recorded, so nothing travels back into the app. | | Redirects | Refused, not followed. A `3xx` is a failure. | ### Your answer decides what happens next | Your answer | What the app does | | --- | --- | | Any `2xx` | Acknowledged. The batch is never sent again. | | No answer, `408`, `425`, `429`, any `5xx`, or `413` with `Retry-After` | Retried with backoff — 15 minutes, doubling to a ceiling of one hour — for up to seven days. | | `400`, `401`, `403`, `404`, `405`, `422`, any `3xx`, a bare `413`, anything else | **Not retried.** The batch is dropped. Only a manual export over a window covering it brings it back. | `Retry-After` is honoured on any status that carries it, clamped to one hour. So answer `4xx` only for a request that will never be acceptable, and `5xx` for your own trouble — a full disk, a database that is down. The example receivers do exactly that: a body that is not JSON, or a `schemaVersion` other than `1`, gets `400`; any other failure gets `500`, and the app keeps the batch and tries again. ### What a correct receiver does - **Upserts every sample on `uuid`.** A `uuid` you already hold is the same sample again — expected and normal, never an error. See "Delivery semantics". - **Keeps the whole sample**, so a key added later is not silently dropped. - **Answers the probe and records nothing.** JSON: `connectivityTest` is `true`. CSV: a header row and no data rows. - **Rejects a `schemaVersion` other than `1`** rather than guessing. - **Parses CSV with a real CSV parser, by header name**, and reads an empty cell as absent, never as `0`. - **Stores each batch all-or-nothing**, so a retried batch never meets half of itself. ### The example receivers Two receivers, one behaviour. Each is a single file with no dependencies beyond the language's standard library, and stores samples in SQLite: one row per `uuid`, the whole sample object in a `payload` column, and one row per accepted request in `deliveries`. | Variable | Default | Meaning | | --- | --- | --- | | `PORT` | `8080` | Port to listen on. `0` picks a free one. | | `HOST` | `127.0.0.1` | Address to bind. `0.0.0.0` accepts the phone. | | `DB_PATH` | `samples.db` | The SQLite file. | | `RECEIVER_TOKEN` | empty | If set, requests must carry `Authorization: Bearer `. | `POST` to any path stores a batch and answers with how many samples were new and how many were duplicates. `GET /stats` answers with totals. Their tests start the real receiver as a process and replay one scenario of synthetic batches, in order: both probes, the contract's worked example as JSON and again as CSV, a two-batch scheduled export with added keys, the same batch sent twice, a CSV batch with a quoted comma, a quote, a newline, empty cells and an appended column, two samples that share one `id`, and a `schemaVersion` `2` body. Each receiver must end with every sample stored once and the right duplicate count. Run both suites from the repository root with `npm run test:examples`. These are starting points, not services: they have no TLS, and `RECEIVER_TOKEN` is a single shared secret. Put a receiver that must be reached from outside your network behind a reverse proxy that terminates TLS. #### Node — `examples/receiver-node/server.mjs` Node 22.13 or later, for the built-in `node:sqlite`. Start it with `node server.mjs`. `examples/receiver-node/server.mjs` — tested against the synthetic fixtures on every change: ```js // A minimal receiver: JSON and CSV batches in, one SQLite row per sample out. // Node 22.13 or later, no dependencies. Run: node server.mjs // // PORT port to listen on (default 8080; 0 = any free port) // HOST address to bind (default 127.0.0.1; use 0.0.0.0 to accept the phone) // DB_PATH SQLite file (default samples.db) // RECEIVER_TOKEN if set, require the header Authorization: Bearer // // POST any path: store the batch. GET /stats: counts, as JSON. import { createServer } from "node:http"; import { DatabaseSync } from "node:sqlite"; const PORT = Number(process.env.PORT ?? 8080); const HOST = process.env.HOST ?? "127.0.0.1"; const TOKEN = process.env.RECEIVER_TOKEN ?? ""; const MAX_BODY = 2 * 1024 * 1024; // the app keeps every batch under 1 MB const db = new DatabaseSync(process.env.DB_PATH ?? "samples.db"); db.exec(` CREATE TABLE IF NOT EXISTS samples ( uuid TEXT PRIMARY KEY, -- the deduplication key, stored exactly as sent id TEXT NOT NULL, -- content fingerprint: repeats by design, never a key type TEXT NOT NULL, grp TEXT NOT NULL, source TEXT NOT NULL, start TEXT NOT NULL, "end" TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL -- the whole sample object, so added keys are kept ); CREATE INDEX IF NOT EXISTS samples_id ON samples (id); CREATE TABLE IF NOT EXISTS deliveries ( -- one row per accepted request, retries included export_id TEXT NOT NULL, batch_sequence INTEGER NOT NULL, is_final INTEGER NOT NULL, basis TEXT, window_start TEXT, window_end TEXT, occasion_start TEXT, occasion_end TEXT, generated_at TEXT, format TEXT NOT NULL, samples INTEGER NOT NULL, inserted INTEGER NOT NULL, duplicates INTEGER NOT NULL, received_at TEXT NOT NULL ); `); const exists = db.prepare("SELECT 1 FROM samples WHERE uuid = ?"); const upsert = db.prepare(` INSERT INTO samples (uuid, id, type, grp, source, start, "end", kind, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uuid) DO UPDATE SET id = excluded.id, type = excluded.type, grp = excluded.grp, source = excluded.source, start = excluded.start, "end" = excluded."end", kind = excluded.kind, payload = excluded.payload`); const logDelivery = db.prepare("INSERT INTO deliveries VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); class Rejected extends Error {} // Store one batch in one transaction: all of it or none of it, so a failed request can // be retried by the app without leaving half a batch behind. function store(envelope, samples, format) { let inserted = 0; let duplicates = 0; db.exec("BEGIN"); try { for (const s of samples) { if (exists.get(s.uuid)) duplicates++; else inserted++; upsert.run(s.uuid, s.id, s.type, s.group, s.source, s.start, s.end, s.kind, JSON.stringify(s)); } const e = envelope; logDelivery.run(e.exportId, e.batchSequence, e.isFinalBatch ? 1 : 0, e.basis ?? null, e.windowStart, e.windowEnd, e.occasionStart ?? null, e.occasionEnd ?? null, e.generatedAt, format, samples.length, inserted, duplicates, new Date().toISOString()); db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); throw err; } return { inserted, duplicates }; } function receiveJson(text) { let body; try { body = JSON.parse(text); } catch { throw new Rejected("body is not JSON"); } if (body.schemaVersion !== 1) throw new Rejected(`unknown schemaVersion ${body.schemaVersion}`); if (body.connectivityTest) return { probe: true }; // acknowledge, record nothing return store(body, body.samples, "json"); } // RFC 4180 parser: quoted fields may hold commas, doubled quotes and newlines. function parseCsv(text) { const rows = []; let row = []; let field = ""; let quoted = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') quoted = false; else field += c; } else if (c === '"') quoted = true; else if (c === ",") { row.push(field); field = ""; } else if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; } else if (c !== "\r") field += c; } if (field !== "" || row.length) { row.push(field); rows.push(row); } return rows; } // A CSV row becomes the same sample object the JSON rendering carries: numbers typed by // `kind`, and an empty cell is an absent key, never 0. function csvSample(r) { const s = { uuid: r.uuid, id: r.id, type: r.type, group: r.group, source: r.source, start: r.start, end: r.end, kind: r.kind }; const num = (k) => { if (r[k] !== "") s[k] = Number(r[k]); }; const str = (k) => { if (r[k] !== "") s[k] = r[k]; }; if (r.kind === "quantity") { num("value"); str("unit"); } else if (r.kind === "category") { s.value = parseInt(r.value, 10); str("name"); } else if (r.kind === "workout") { s.activityType = parseInt(r.activityType, 10); str("activityName"); num("durationSeconds"); num("distanceMeters"); num("activeEnergyKilocalories"); } return s; } function receiveCsv(text) { const [header, ...data] = parseCsv(text); if (!header || !header.includes("uuid")) throw new Rejected("no CSV header row"); if (data.length === 0) return { probe: true }; // header, no rows: the probe // Parse by header name: columns may be appended, never reordered. const rows = data.map((cells) => Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ""]))); if (rows.some((r) => r.schemaVersion !== "1")) throw new Rejected("unknown schemaVersion"); const r0 = rows[0]; const envelope = { ...r0, batchSequence: Number(r0.batchSequence), isFinalBatch: r0.isFinalBatch === "true" }; return store(envelope, rows.map(csvSample), "csv"); } function stats() { const one = (sql) => Object.values(db.prepare(sql).get())[0] ?? 0; return { samples: one("SELECT COUNT(*) FROM samples"), duplicates: one("SELECT COALESCE(SUM(duplicates), 0) FROM deliveries"), deliveries: one("SELECT COUNT(*) FROM deliveries"), batches: one("SELECT COUNT(*) FROM (SELECT DISTINCT export_id, batch_sequence FROM deliveries)"), }; } const server = createServer((req, res) => { const reply = (status, obj) => { res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(obj)); }; if (req.method === "GET" && req.url === "/stats") return reply(200, stats()); if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) return reply(401, { error: "credential" }); if (req.method !== "POST") return reply(405, { error: "POST a batch" }); const chunks = []; let size = 0; req.on("data", (c) => { size += c.length; if (size <= MAX_BODY) chunks.push(c); }); req.on("end", () => { // A bare 413 is permanent: the app does not retry it. if (size > MAX_BODY) return reply(413, { error: "body too large" }); const text = Buffer.concat(chunks).toString("utf8"); const type = (req.headers["content-type"] ?? "").toLowerCase(); try { if (type.startsWith("application/json")) return reply(200, receiveJson(text)); if (type.startsWith("text/csv")) return reply(200, receiveCsv(text)); return reply(415, { error: "expected application/json or text/csv" }); } catch (err) { // A 4xx is not retried: only for a body that will never be accepted. Anything else // is our fault, and a 500 makes the app keep the batch and try again later. if (err instanceof Rejected) return reply(400, { error: err.message }); console.error(err); return reply(500, { error: "internal" }); } }); }); server.listen(PORT, HOST, () => console.log(`listening on ${HOST}:${server.address().port}`)); ``` #### Python — `examples/receiver-python/receiver.py` Python 3.11 or later, standard library only. Start it with `python3 receiver.py`. (`examples/receiver-python/receiver.py` is published on the documentation site and left out of this file: it behaves exactly like the Node receiver above.) ## JSON payload This page describes a destination set to JSON. A destination set to CSV sends the same samples as a CSV document (see "CSV"). Everything here is version 1: keys keep their names, types and meanings for as long as the app emits `schemaVersion` `1`. The only change allowed inside version 1 is that **new keys may appear** — so ignore keys you do not recognise. ### The envelope Every request body — an export batch or the connectivity probe — is one object with exactly these twelve keys, always all present. | Key | Type | Meaning | | --- | --- | --- | | `schemaVersion` | integer | Always `1` today. Check it before anything else, and reject any other value. | | `connectivityTest` | boolean | `true` only on the probe, which carries no samples. Do not record a payload with `true` here. | | `exportId` | string (UUID) | Shared by every batch of one export run. | | `generatedAt` | timestamp | When the run's batches were built — one instant, identical on every batch of the run. Not when any sample was recorded. | | `batchSequence` | integer | 1-based, incrementing within one run. | | `isFinalBatch` | boolean | `true` on the last batch of the run and no other. | | `basis` | string | `window` or `anchor`: which mechanism produced the batch. See "Delivery semantics". | | `windowStart` | timestamp | No sample in the batch starts before it, on either `basis`. | | `windowEnd` | timestamp | No sample in the batch ends after it, on either `basis`. Exclusive on `basis` `window`. | | `occasionStart` | timestamp | Start of the run this batch belongs to; identical on every batch of the run. A label, not a filter. | | `occasionEnd` | timestamp | End of that run. | | `samples` | array | The batch's samples. Empty on the probe; never absent. | ### The sample object Every sample carries these eight keys, always: | Key | Type | Meaning | | --- | --- | --- | | `uuid` | string (UUID) | Apple Health's own identifier for the sample. **The deduplication key.** | | `id` | string | 64 lowercase hex characters: a SHA-256 fingerprint of the sample's content. **Not a key** — it repeats by design. | | `type` | string | Apple's type identifier, e.g. `HKQuantityTypeIdentifierStepCount`. See the catalog. | | `group` | string | One of `activity`, `heart`, `body`, `sleep`, `vitals`, `workouts`. A type belongs to one group, forever. | | `source` | string | The bundle identifier of whatever recorded the sample. | | `start` | timestamp | When the reading began. | | `end` | timestamp | When it ended; equal to `start` for an instantaneous reading. | | `kind` | string | `quantity`, `category` or `workout`. **The discriminator**: it decides which keys follow and what `value` means. | Then, per `kind`: #### `kind` `quantity` — a number in a unit | Key | Type | Meaning | | --- | --- | --- | | `value` | number | Always present. Decode as floating point. | | `unit` | string | Always present; one of the units. | #### `kind` `category` — an enumerated state | Key | Type | Meaning | | --- | --- | --- | | `value` | integer | Always present: Apple's raw enum value. | | `name` | string | Present only when this build has a name for that integer. | Sleep analysis is the only category type in version 1: | `value` | `name` | | --- | --- | | 0 | `inBed` | | 1 | `asleepUnspecified` | | 2 | `awake` | | 3 | `asleepCore` | | 4 | `asleepDeep` | | 5 | `asleepREM` | An integer this build does not know arrives with no `name` key at all. Key on `value`. #### `kind` `workout` — a recorded session | Key | Type | Meaning | | --- | --- | --- | | `activityType` | integer | Always present: Apple's `HKWorkoutActivityType` raw value. | | `activityName` | string | Present only when this build has a name for that integer, in lowerCamelCase: `running`, `cycling`, `traditionalStrengthTraining`… | | `durationSeconds` | number | Always present. | | `distanceMeters` | number | Present only when the workout recorded a distance. | | `activeEnergyKilocalories` | number | Present only when the workout recorded energy. | A workout has no `value` and no `unit`; the units are in the key names. Activity types `14`, `15` and `30` are ones Apple deprecated and arrive unnamed. ### Absent, never null An optional with no value is an **absent key**. The payload contains no `null` anywhere. A workout with no distance has no `distanceMeters` key; it never has `"distanceMeters": null`, and it never has `0`. ### Numbers and identifiers - **A whole number has no decimal point.** `42` steps and a `1800`-second workout are bare integers on the wire, though they are measurements. So `value` `42` on a quantity and `value` `4` on a category look alike. **Switch on `kind`**: decode a quantity's `value` as floating point and a category's as an integer. - **UUIDs are uppercase**, in canonical 8-4-4-4-12 form, in both `exportId` and `uuid`. If your storage changes case anywhere, change it everywhere, or deduplication silently stops working. ### Units `unit` is always one of these spellings. The list may grow; no spelling will change. | `unit` | Meaning | | --- | --- | | `count` | a dimensionless count | | `count/min` | counts per minute | | `m` | metres | | `kcal` | kilocalories | | `min` | minutes | | `ms` | milliseconds | | `kg` | kilograms | | `fraction` | a proportion in `0…1` | | `ml/(kg*min)` | millilitres per kilogram per minute | | `degC` | degrees Celsius | | `mmHg` | millimetres of mercury | Heart rate and respiratory rate are `count/min`, not per second. Blood oxygen and body fat are a `fraction`: `0.98`, not `98` — multiply by 100 yourself for a percentage. Slashes and parentheses arrive unescaped. ### Dates Every timestamp is ISO 8601 in exactly this shape: full date, `T`, full time, **always three fractional digits**, and a numeric UTC offset with a colon — or `Z` for UTC. `2026-08-21T10:25:30.000+02:00` and `2026-08-21T08:25:30.000Z` are the same instant. The offset is the exporting phone's at the moment of encoding, not the sample's, so a user who travels resends a historic sample under a different offset. Store instants in UTC. Two distinct samples can carry identical `start` and `end`: milliseconds are the limit of the format. ### The catalog Six groups, each enabled independently by the user. Expect any subset: an export carrying only `sleep` is normal. | `group` | `type` | `kind` | `unit` | | --- | --- | --- | --- | | `activity` | `HKQuantityTypeIdentifierStepCount` | quantity | `count` | | `activity` | `HKQuantityTypeIdentifierDistanceWalkingRunning` | quantity | `m` | | `activity` | `HKQuantityTypeIdentifierFlightsClimbed` | quantity | `count` | | `activity` | `HKQuantityTypeIdentifierActiveEnergyBurned` | quantity | `kcal` | | `activity` | `HKQuantityTypeIdentifierBasalEnergyBurned` | quantity | `kcal` | | `activity` | `HKQuantityTypeIdentifierAppleExerciseTime` | quantity | `min` | | `activity` | `HKQuantityTypeIdentifierAppleStandTime` | quantity | `min` | | `heart` | `HKQuantityTypeIdentifierHeartRate` | quantity | `count/min` | | `heart` | `HKQuantityTypeIdentifierRestingHeartRate` | quantity | `count/min` | | `heart` | `HKQuantityTypeIdentifierWalkingHeartRateAverage` | quantity | `count/min` | | `heart` | `HKQuantityTypeIdentifierHeartRateVariabilitySDNN` | quantity | `ms` | | `body` | `HKQuantityTypeIdentifierBodyMass` | quantity | `kg` | | `body` | `HKQuantityTypeIdentifierHeight` | quantity | `m` | | `body` | `HKQuantityTypeIdentifierBodyMassIndex` | quantity | `count` | | `body` | `HKQuantityTypeIdentifierBodyFatPercentage` | quantity | `fraction` | | `body` | `HKQuantityTypeIdentifierLeanBodyMass` | quantity | `kg` | | `sleep` | `HKCategoryTypeIdentifierSleepAnalysis` | category | — | | `vitals` | `HKQuantityTypeIdentifierOxygenSaturation` | quantity | `fraction` | | `vitals` | `HKQuantityTypeIdentifierRespiratoryRate` | quantity | `count/min` | | `vitals` | `HKQuantityTypeIdentifierVO2Max` | quantity | `ml/(kg*min)` | | `vitals` | `HKQuantityTypeIdentifierBodyTemperature` | quantity | `degC` | | `vitals` | `HKQuantityTypeIdentifierBloodPressureSystolic` | quantity | `mmHg` | | `vitals` | `HKQuantityTypeIdentifierBloodPressureDiastolic` | quantity | `mmHg` | | `workouts` | `HKWorkoutTypeIdentifier` | workout | — | Workout routes (GPS traces) are not sent and will not be added to version 1. Heart rate recorded during a workout arrives as ordinary `heart` samples. ### The connectivity probe **Test connection** sends the envelope with no samples and `connectivityTest` `true` — always exactly these bytes. The nil `exportId` and the epoch `generatedAt` are fixed constants. Answer `2xx` and record nothing. `examples/fixtures/probe.json` — tested against the synthetic fixtures on every change: ```json {"basis":"window","batchSequence":1,"connectivityTest":true,"exportId":"00000000-0000-0000-0000-000000000000","generatedAt":"1970-01-01T00:00:00.000Z","isFinalBatch":true,"occasionEnd":"1970-01-01T00:00:00.000Z","occasionStart":"1970-01-01T00:00:00.000Z","samples":[],"schemaVersion":1,"windowEnd":"1970-01-01T00:00:00.000Z","windowStart":"1970-01-01T00:00:00.000Z"} ``` ### Worked example One batch carrying all three kinds: 42 steps, 45 minutes of deep sleep, and a 30-minute 5 km run. These are the contract's own example bytes; on the wire the object is unindented, with no trailing newline. `examples/fixtures/contract-example.json`, indented for reading — tested against the synthetic fixtures on every change: ```json { "basis": "window", "batchSequence": 1, "connectivityTest": false, "exportId": "6C7F1E22-1B3D-4A5E-9F00-2C1D4E5A6B70", "generatedAt": "2026-08-21T12:00:00.000+02:00", "isFinalBatch": true, "occasionEnd": "2026-08-21T12:00:00.000+02:00", "occasionStart": "2026-08-20T12:00:00.000+02:00", "samples": [ { "end": "2026-08-21T10:25:30.000+02:00", "group": "activity", "id": "3cae82ce29b729ba872bdd31f1db79f0a31013c3546710bd4fe1adb8192ec011", "kind": "quantity", "source": "com.apple.Health", "start": "2026-08-21T10:25:30.000+02:00", "type": "HKQuantityTypeIdentifierStepCount", "unit": "count", "uuid": "A1B2C3D4-0000-4000-8000-000000000001", "value": 42 }, { "end": "2026-08-21T00:55:00.000+02:00", "group": "sleep", "id": "8b8f956b04e59162cf497570f22a2f9f96f82b5ffddc090b0c060ba910f14e21", "kind": "category", "name": "asleepDeep", "source": "com.apple.Health", "start": "2026-08-21T00:10:00.000+02:00", "type": "HKCategoryTypeIdentifierSleepAnalysis", "uuid": "A1B2C3D4-0000-4000-8000-000000000002", "value": 4 }, { "activeEnergyKilocalories": 320, "activityName": "running", "activityType": 37, "distanceMeters": 5000, "durationSeconds": 1800, "end": "2026-08-21T07:30:00.000+02:00", "group": "workouts", "id": "689c84ea50249537ec039e00b603c06985d1c24a864d02cba7261764e669e477", "kind": "workout", "source": "com.apple.Health", "start": "2026-08-21T07:00:00.000+02:00", "type": "HKWorkoutTypeIdentifier", "uuid": "A1B2C3D4-0000-4000-8000-000000000003" } ], "schemaVersion": 1, "windowEnd": "2026-08-21T12:00:00.000+02:00", "windowStart": "2026-08-20T12:00:00.000+02:00" } ``` ## CSV A destination set to CSV gets the same samples, window, batching and transport as a JSON (see "JSON payload") destination, rendered as a CSV document. Only the bytes differ, and the `schemaVersion` column carries the same version, `1`. If you read nothing else: 1. **Deduplicate on the `uuid` column**, never on `id`. 2. **Parse by header name, with a real CSV parser.** New columns are appended at the end; ignore the ones you do not recognise. Splitting on commas breaks on the first recorder whose name contains one. 3. **An empty cell means absent — never zero.** 4. **Answer with any `2xx`.** The rules in "Receiver setup" apply unchanged. ### Transport `Content-Type` is `text/csv; charset=utf-8`, always. The body is one CSV document, UTF-8, no byte-order mark, LF row terminators, every row terminated including the last. Everything else — method, address, credential, status codes, timeouts — is the same as for JSON. ### The columns 26 columns, in this order, on every batch, led by a header row. The first seven and the last three are the envelope, repeated on every row, so any single row is self-describing. The last three came later: new columns append at the end rather than joining their block. | Column | Type | Populated on | | --- | --- | --- | | `schemaVersion` | integer | every row | | `exportId` | string (UUID) | every row | | `generatedAt` | timestamp | every row | | `batchSequence` | integer | every row | | `isFinalBatch` | boolean | every row | | `windowStart` | timestamp | every row | | `windowEnd` | timestamp | every row | | `uuid` | string (UUID) | every row — the deduplication key | | `id` | string | every row — a fingerprint, not a key | | `type` | string | every row | | `group` | string | every row | | `source` | string | every row — the column most likely to need quoting | | `start` | timestamp | every row | | `end` | timestamp | every row | | `kind` | string | every row — the discriminator | | `value` | number or integer | `quantity` (floating point), `category` (integer) | | `unit` | string | `quantity` | | `name` | string | `category`, when this build has a name for the value | | `activityType` | integer | `workout` | | `activityName` | string | `workout`, when named | | `durationSeconds` | number | `workout` | | `distanceMeters` | number | `workout`, when a distance was recorded | | `activeEnergyKilocalories` | number | `workout`, when energy was recorded | | `basis` | string | every row | | `occasionStart` | timestamp | every row | | `occasionEnd` | timestamp | every row | Each column means what the key of the same name means in the "JSON payload". `connectivityTest` is not a column: the CSV probe is recognised by its shape instead. ### Dialect RFC 4180, with the open choices pinned: - UTF-8 without a byte-order mark, LF line endings, every row terminated. - A header row leads every batch, because each batch must parse on its own. - **Minimal quoting**: a field is quoted only when it contains a comma, a double quote, LF or CR; an embedded double quote is doubled. There is no backslash escape. - No padding and no space after a separator. A quoted field may contain a newline, so a physical line is not a row. In practice only `source` and `activityName` can need quoting — which is why they must be handled. ### Empty cells Eight columns can be empty: `value`, `unit`, `name` and the five workout columns. The other eighteen are always populated. A `quantity` row leaves `name` and the workout columns empty; a `category` row leaves `unit` and the workout columns empty, and `name` when it is unnamed; a `workout` row leaves `value`, `unit` and `name` empty. **Coercing empty to `0` corrupts your data**: an empty `distanceMeters` becomes a workout that covered no distance. `pandas.read_csv` (`NaN`) and DuckDB's `read_csv_auto` (`NULL`) are correct as they stand; a `fillna(0)` or a strictly typed column breaks them. ### Numbers Numbers are spelled exactly as in JSON: shortest round-trip form, no decimal point on a whole number, `.` as the separator, no thousands separators, exponent form for very large or small magnitudes, never quoted. Take the type of `value` from `kind`, never from how it looks. ### Appending bodies to one file Every row carries its envelope and every row is terminated, so appending each body to one file almost works. The one catch is that every batch has a header row: **skip any line equal to the header row** before parsing. No data row can equal it, because a data row starts with `1`, not `schemaVersion`. ### The probe **Test connection** on a CSV destination sends the header row and nothing else: a complete CSV document with no data rows, and so no `exportId` at all. A body with a header and no data rows is a probe — an export never sends one, because a run that finds nothing sends no request. Answer `2xx` and record nothing. `examples/fixtures/probe.csv` — tested against the synthetic fixtures on every change: ```csv schemaVersion,exportId,generatedAt,batchSequence,isFinalBatch,windowStart,windowEnd,uuid,id,type,group,source,start,end,kind,value,unit,name,activityType,activityName,durationSeconds,distanceMeters,activeEnergyKilocalories,basis,occasionStart,occasionEnd ``` ### Worked example The same three samples as the JSON worked example, as the exact bytes sent. The empty cells are absences: the workout row has no `value`, `unit` or `name`, and the other two rows have no workout cells. None of them is a `0`. `examples/fixtures/contract-example.csv` — tested against the synthetic fixtures on every change: ```csv schemaVersion,exportId,generatedAt,batchSequence,isFinalBatch,windowStart,windowEnd,uuid,id,type,group,source,start,end,kind,value,unit,name,activityType,activityName,durationSeconds,distanceMeters,activeEnergyKilocalories,basis,occasionStart,occasionEnd 1,6C7F1E22-1B3D-4A5E-9F00-2C1D4E5A6B70,2026-08-21T12:00:00.000+02:00,1,true,2026-08-20T12:00:00.000+02:00,2026-08-21T12:00:00.000+02:00,A1B2C3D4-0000-4000-8000-000000000001,3cae82ce29b729ba872bdd31f1db79f0a31013c3546710bd4fe1adb8192ec011,HKQuantityTypeIdentifierStepCount,activity,com.apple.Health,2026-08-21T10:25:30.000+02:00,2026-08-21T10:25:30.000+02:00,quantity,42,count,,,,,,,window,2026-08-20T12:00:00.000+02:00,2026-08-21T12:00:00.000+02:00 1,6C7F1E22-1B3D-4A5E-9F00-2C1D4E5A6B70,2026-08-21T12:00:00.000+02:00,1,true,2026-08-20T12:00:00.000+02:00,2026-08-21T12:00:00.000+02:00,A1B2C3D4-0000-4000-8000-000000000002,8b8f956b04e59162cf497570f22a2f9f96f82b5ffddc090b0c060ba910f14e21,HKCategoryTypeIdentifierSleepAnalysis,sleep,com.apple.Health,2026-08-21T00:10:00.000+02:00,2026-08-21T00:55:00.000+02:00,category,4,,asleepDeep,,,,,,window,2026-08-20T12:00:00.000+02:00,2026-08-21T12:00:00.000+02:00 1,6C7F1E22-1B3D-4A5E-9F00-2C1D4E5A6B70,2026-08-21T12:00:00.000+02:00,1,true,2026-08-20T12:00:00.000+02:00,2026-08-21T12:00:00.000+02:00,A1B2C3D4-0000-4000-8000-000000000003,689c84ea50249537ec039e00b603c06985d1c24a864d02cba7261764e669e477,HKWorkoutTypeIdentifier,workouts,com.apple.Health,2026-08-21T07:00:00.000+02:00,2026-08-21T07:30:00.000+02:00,workout,,,,37,running,1800,5000,320,window,2026-08-20T12:00:00.000+02:00,2026-08-21T12:00:00.000+02:00 ``` ## Shortcuts The app publishes one Shortcuts action. It runs the same export the **Export Now** button runs — same time ranges, same destination, same format, same bytes — with no screen, so an automation can export on a schedule you choose. What arrives at your endpoint is exactly the JSON (see "JSON payload") or "CSV" described on those pages. If you read nothing else: 1. **The action throws on failure.** A failed export and an empty time range never look alike. 2. **On a local-network destination, export once in the app first.** Until you have, the action refuses and sends nothing. 3. **One export runs at a time.** An action that fires while an export is running is refused, not queued. 4. **The action's name, its parameter and the range identifiers are frozen**, so a saved Shortcut keeps working. ### The action | Field | Value | | --- | --- | | Action name | Export Health Data | | Parameter name | Time Range | Find it in the Shortcuts action library under the app's name. It runs on the iPhone that has the app; there is no web or Mac counterpart. It does not open the app when it runs. ### Time Range | Time Range | Saved identifier | What it exports | | --- | --- | --- | | Last 3 hours | `last3Hours` | the 3 hours ending when the action runs | | Last 24 hours | `last24Hours` | the 24 hours ending when the action runs | | Last 7 days | `last7Days` | the 7 days ending when the action runs | | Last 30 days | `last30Days` | the 30 days ending when the action runs | A range is a fixed span back from the moment the action fires, not a calendar range: "Last 24 hours" is not "since midnight". It includes its start and excludes its end, so back-to-back runs tile. There is no custom date range. "Last 3 hours" is the narrowest: a Shortcut that runs less often than every three hours leaves gaps with it. Every run of the action is a `basis` `window` export: overlapping runs resend the overlap by design, and a receiver that upserts on `uuid` absorbs it. ### What it returns - **Result** — an integer: how many samples the receiver accepted. `0` when the range held nothing. An empty range is not an error, and sends no request at all. - **Dialog** — the export's own headline, such as `Sent 220 samples` or `Nothing to send`. ### Errors Every failure throws: your Shortcut stops and shows the app's own message. Wrap the action in an error handler to handle it yourself. | Situation | What it means | | --- | --- | | No destination yet | Set the receiver's address in the app first. Nothing was read or sent. | | No metrics chosen yet | Turn on at least one group in the app first. | | Local network not explained yet | The one-time in-app export has not happened. Open the app and export once. | | An export is already running | Nothing was read or sent. Safe to retry — after a moment, not in a tight loop. | | Nothing arrived | The first batch was refused or unanswered; the message carries the status. Nothing was delivered, so re-running is safe. | | Partly sent | Some batches landed, then one failed. The failed batch is retried later on the app's schedule; the batches after it were never built. | | Couldn't read from Health | Usually a locked phone. The message counts what had already arrived. | | Couldn't read its own settings | The phone restarted and has not been unlocked yet. Unlock it and run again. | After **Partly sent** or **Couldn't read from Health**, the run is not resumed: exporting the same range again is what fetches the rest, and it resends what already arrived. ### What the action does not do No schedule of its own, no retry or backoff inside the run, no queue in front of it, no entry in a log of its own, no format or metric-group parameter (both come from the app's settings), no custom date range, and no Siri phrase. It sends nothing anywhere but your endpoint. ### A worked automation Export every hour over the last 24 hours. A missed run — a locked phone, a restart, an export already running — is covered by the next ones, and `uuid` deduplication makes the overlap free. Illustrative, not executed: ```text Automation: Time of Day, every hour 1. Export Health Data Time Range: Last 24 hours (on error: show notification with the error, stop) 2. If Result is 0 (nothing to do) Otherwise Add "Result" samples to a running log ``` ## Delivery semantics ### Runs and batches An export run delivers what it read as **one or more batches, sent one after another**, each its own HTTP request. - Every batch of a run carries the same `exportId`, `generatedAt`, `basis`, `occasionStart` and `occasionEnd`. - `batchSequence` starts at `1` and goes up by one. - `isFinalBatch` is `true` on the last batch only. - A batch carries at most 1000 samples. To reassemble a run, group by `exportId`, order by `batchSequence`, and call it complete when you hold a contiguous `1…N` ending in `isFinalBatch` `true`. ### A run can stop early If a batch fails — your receiver is down, answers outside `2xx`, or the network drops — the run ends there, and you hold batches `1…k` with no final one. That run is **truncated, not invalid**: keep what arrived, and do not hold it hostage to what has not. The failed batch may still come. The app keeps the batch it built and tries it again later, with backoff: 15 minutes, doubling to a ceiling of one hour. A retry is **the same bytes**, under the same `exportId` and `batchSequence` — one batch tried twice, never a second run. So batch `k+1` can arrive hours or days after batch `k`; do not time out an `exportId`. Whether it keeps trying depends on your answer (see "Receiver setup"), and **the wait is bounded at seven days**: pending work older than that is dropped and never fetched again. The only way back is a manual export over a range that covers the loss, which is a new `exportId` and resends everything in that range. ### Exactly once — and the one allowed duplicate The app's promise is narrow and exact: **it never sends the same record twice, and it publishes the key you upsert on.** The scheduled export resumes from a stored position, so it does not re-send what it has already delivered, across app kills, restarts and lost networks. There is **one allowed duplicate**: if the app is stopped after your receiver accepted a batch but before the app recorded the acknowledgement, the next attempt re-sends that batch — the identical bytes, under the identical `exportId`, `batchSequence` and `uuid`s. Either the app risks a re-send or it risks a loss, and losing data is the worse failure. Two other ways the same `uuid` reaches you again, both by design: - **A retry you never saw succeed** — for example, your receiver stored the batch but the answer did not reach the phone in time. - **Overlapping ranges.** A manual or Shortcuts export (`basis` `window`) sends the whole range it was given and tracks no position, so two such exports over overlapping ranges resend the overlap. **Upsert on `uuid`** and all three are free. A receiver that inserts blindly holds each of them twice. ### `uuid` and `id` `uuid` is Apple Health's own identifier for the sample: unique, and stable for the life of the record on that device. **Make it your primary key and upsert on it**; a newer copy replaces the older one. `id` is a fingerprint of the sample's content — type, start, end, value. Two records that assert the same reading over the same span share it **by design**. On a real device, 720 samples over one day carried only 195 distinct `id`s, because one app stamps every heart-rate reading with its containing hour. Deduplicating on `id` would have kept 195 of 720. What `id` is good for: it survives a re-mint of `uuid` — a Health data reinstall or a device migration that assigns new identifiers. Index it as a secondary, non-unique column if you want to notice that case. #### What neither key detects Apple Health sometimes **coalesces** historic samples — two step samples of 30 and 70 replaced by one of 100 over the same span. That is a new record with a new `uuid` and a new `id`, and version 1 carries no deletions, so you will hold all three. **A sum over that span counts twice.** Read version 1 data as append-only, latest wins per `uuid`. ### Window and occasion `basis` says which mechanism produced a batch, and so what `windowStart` / `windowEnd` are evidence of. On **both**, every sample in the batch lies inside the window, so filtering or bucketing on it is always safe. - **`window`** — a manual or Shortcuts export. The window is the range that was asked for: a complete statement that nothing inside it was withheld. - **`anchor`** — the scheduled export. There is no range: the app resumes from where it last delivered, and the window is just the bracket of this batch's samples. A later batch can carry earlier dates, and a gap between two brackets means nothing. `occasionStart` / `occasionEnd` label the run: identical on every batch of it. On `anchor`, consecutive occasions tile time end to end; on `window`, the occasion is the window. **Never filter on the occasion**: a scheduled export exists to deliver readings written long after the dates they carry, and those fall outside it. ### Order Within a batch, samples arrive grouped by type and ascending by `start`, but that is not part of the contract. Sort by `start` yourself if you need an order. ## Limits ### The schedule is a target, not a promise The scheduled export aims to send data **typically within an hour** of it arriving in Health. iOS decides when the app actually wakes, so there is no realtime delivery and no schedule in seconds or minutes. Build your dashboard for data that is usually recent, not for data that is always current. ### What stops scheduled delivery - **Low Power Mode** stops scheduled export outright while it is on. - **Swiping the app away** in the app switcher stops it until you open the app again. No app can override this. Work that was queued is kept on the phone, not lost: it goes out after the app is opened. Delivery does not continue on its own while the app is swiped away. - **A locked phone.** Health data cannot be read while the phone is locked. A background wake while locked can still deliver batches that are already queued; reading new samples waits until the phone is unlocked. An export started from a Shortcut while the phone is locked stops with "Couldn't read from Health". - **A restart before the first unlock.** Until the phone has been unlocked once after a restart, a background wake does nothing and leaves everything as it was; the next wake after unlock does the work. ### How long the app keeps trying A failed batch is retried with backoff, from 15 minutes up to once an hour, for **seven days**. Pending work older than that is dropped, and the app does not go back for it. A batch your receiver answered with a status that is not retried (a `4xx` such as `401` or `404`, a redirect, a bare `413`) is dropped at once. The only way back after either is a manual export over a range that covers the loss. ### One export at a time The button, the Shortcuts action and the scheduled export share one gate. A second export that starts while one is running is refused, not queued. ### What version 1 of the payload cannot tell you - **Deletions.** A sample deleted in Health after it was delivered stays in your copy. Your copy only grows; reconcile out of band if you need to. - **Coalescing.** When Health replaces fine-grained historic samples with a combined one, you receive both, with no signal that the originals are gone. Sums over that span double-count. - **Backdated samples, on a manual or Shortcuts export.** A range matches on a sample's `start`, so a sample written today with last week's date is not in a "Last 24 hours" export. The scheduled export does deliver it, on its next run, but no sample says when it was written. - **Workout routes.** GPS traces are not sent and will not be added to version 1. ### Time ranges Manual and Shortcuts exports offer four fixed ranges — 3 hours, 24 hours, 7 days, 30 days, each ending now. There is no custom date range.