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.

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 uuids. 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:

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 ids, 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.

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.