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,occasionStartandoccasionEnd. batchSequencestarts at1and goes up by one.isFinalBatchistrueon 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 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:
- 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 (
basiswindow) 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 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.
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.