CSV
A destination set to CSV gets the same samples, window, batching and transport as a
JSON destination, rendered as a CSV document. Only the bytes differ, and
the schemaVersion column carries the same version, 1.
If you read nothing else:
- Deduplicate on the
uuidcolumn, never onid. - 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.
- An empty cell means absent — never zero.
- 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 by npm run test:examplesschemaVersion,exportId,generatedAt,batchSequence,isFinalBatch,windowStart,windowEnd,uuid,id,type,group,source,start,end,kind,value,unit,name,activityType,activityName,durationSeconds,distanceMeters,activeEnergyKilocalories,basis,occasionStart,occasionEndWorked 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 by npm run test:examplesschemaVersion,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