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:

  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.

ColumnTypePopulated on
schemaVersionintegerevery row
exportIdstring (UUID)every row
generatedAttimestampevery row
batchSequenceintegerevery row
isFinalBatchbooleanevery row
windowStarttimestampevery row
windowEndtimestampevery row
uuidstring (UUID)every row — the deduplication key
idstringevery row — a fingerprint, not a key
typestringevery row
groupstringevery row
sourcestringevery row — the column most likely to need quoting
starttimestampevery row
endtimestampevery row
kindstringevery row — the discriminator
valuenumber or integerquantity (floating point), category (integer)
unitstringquantity
namestringcategory, when this build has a name for the value
activityTypeintegerworkout
activityNamestringworkout, when named
durationSecondsnumberworkout
distanceMetersnumberworkout, when a distance was recorded
activeEnergyKilocaloriesnumberworkout, when energy was recorded
basisstringevery row
occasionStarttimestampevery row
occasionEndtimestampevery 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:

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:examples
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 by npm run test:examples
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