All docs

Reference

Bulk export

Source: docs/export.md on GitHub · edit this page · Plain Markdown

Choose the source first:

You want to exportUse
Data directly from your iPhone, without a serverThe app's Export tab
Data already synced to your databaseThe server API or puls-export CLI
A selection someone has asked you to share onceData Requests

For spreadsheets, start with CSV. Use JSON or JSONL on the phone when you need full record details. Server exports support CSV and JSONL. A server export contains only data already uploaded; it cannot read more from HealthKit.

GET /v1/export on the product API returns a whole range of one dataset as a file — CSV for a spreadsheet, JSONL for a notebook or a chat attachment — instead of the JSON document the other endpoints return. It streams: the rows go out as they are read, so an export of a busy type is bounded by the disk it lands on rather than by the server's memory.

tools/puls-export is a small client for it. Everything below works equally with curl. Install the CLI, then load your trusted local server/.env from the repository root. Keep tokens out of chats:

set -a
. server/.env
set +a
export PULS_API_URL=http://127.0.0.1:8081

Run on the server or use a reachable HTTPS API URL. The examples below use fixed illustrative dates; choose a range where you have synced data. CLI date bounds use $PULS_TIME_ZONE (UTC if unset); set --time-zone to the account reporting zone when it differs.

# a year of sleep, as a spreadsheet
puls-export --dataset sleep --start 2026-01-01 --end 2027-01-01 -o sleep.csv
 
# a week of raw heart rate, one JSON object per line
puls-export --dataset samples --type HKQuantityTypeIdentifierHeartRate \
  --format jsonl --start 2026-01-01 --end 2026-01-08 > heart-rate.jsonl
 
# the same thing with curl; -OJ takes the filename from the response
curl -fL -H "Authorization: Bearer $PULS_API_TOKEN" -OJ \
  "$PULS_API_URL/v1/export?format=csv&dataset=sleep&start=1767225600000&end=1798761600000"

Parameters#

ParameterRequiredMeaning
formatyescsv or jsonl
datasetyesone of the six below
start, endyesepoch milliseconds, [start, end)
typesdaily_metrics onlycomma-separated HealthKit identifiers
typesamples onlyexactly one HealthKit identifier
activityTypeworkouts onlykeep one activity type
usernothe user to export, a UUID (see Whose data below)

limit and offset do not apply: an export is bounded by its range, not by a page size, and workouts returns the whole range rather than one page.

Whose data. Every export is one user's. Without user it is the server's default (PULS_USER_ID), as every other endpoint. user=<uuid> asks for someone else — GET /v1/users lists who exists — and the server allows that only with PULS_MULTI_USER=true; otherwise it answers 403 {"error": "multi-user reads are disabled"} rather than quietly exporting the default user's data under another name. A value that is not a UUID is a 400. Neither refusal counts against the failed-authentication limit.

Range caps. samples keeps the 31 days /v1/samples enforces — a busy type runs to hundreds of thousands of rows a month. Every other dataset is capped at 366 days, like /v1/sleep/daily and /v1/state-of-mind. That is stricter than /v1/metrics/daily and /v1/workouts, which page instead (and /v1/activity/summary, one small row per day): a file is bounded only by its range, so it needs a cap. The cap measures the instant span; the day-grained datasets also reject a range that touches more than 366 local calendar days, with their own message. Either way it is a 400 naming the limit, before the first byte of the file, in the JSON endpoints' shape: {"error": "range must not exceed 31 days"}.

At most two exports run at once. Each holds a database connection for the length of the download, and the pool is small, so a third request is refused immediately with a 503 and a Retry-After header rather than queued behind them — waiting would tie up the connection the limit exists to protect.

Datasets and their columns#

The CSV header row and the JSONL object keys are the same list, in the same order, so the two formats can never describe different rows. Field names are the JSON endpoints' names; where an endpoint nests, the export flattens — the identifying fields repeat on every row, and a nested field is named by its path.

datasetFromColumns
daily_metrics/v1/metrics/dailyidentifier, unit, date, value
samples/v1/samplestype, unit, uuid, start, end, value, label, source
workouts/v1/workoutsuuid, activityType, start, end, durationS, distanceM, energyKcal, hasRoute, availableMetrics
sleep/v1/sleep/dailydate, start, end, inBedMinutes, asleepMinutes, stages.core, stages.deep, stages.rem, stages.unspecified, stages.awake, sources
activity/v1/activity/summarydate, moveKcal, moveGoalKcal, exerciseMin, exerciseGoalMin, standHours, standGoalHours, moveMode, moveTimeMin, moveTimeGoalMin
state_of_mind/v1/state-of-minduuid, date, timestamp, kind, valence, valenceClassification, labels, associations

Values follow the same rules as the JSON endpoints — epoch milliseconds for instants, YYYY-MM-DD calendar days in the selected account's reporting zone for raw-day grouping, preserved phone dates for rings and daily phone aggregates, and canonical units — with two format-specific conventions:

  • A null is an empty CSV cell and an explicit null in JSONL, never a zero and never a missing key. Every line of a dataset has the same shape.
  • A list (availableMetrics, labels, associations) is comma-joined inside its quoted CSV cell and stays a JSON array in JSONL.

samples is not deduplicated across devices, exactly like /v1/samples: if an iPhone and an Apple Watch recorded the same minutes, both rows are there. Use daily_metrics for totals.

One thing to know before double-clicking a CSV: cells are written verbatim, so a value that begins with =, +, - or @ is a formula to a spreadsheet. Text fields such as source names and labels may come from other apps. Import the file as text — or use JSONL — if you do not trust every app that has ever written to your Health store.

How it streams#

The response carries no Content-Length, so it is framed Transfer-Encoding: chunked and the file starts arriving before the query has finished. The first push happens before any row is read — it carries the CSV header row, or for JSONL just the response head — and rows follow in flush windows. Nothing is buffered to the length of the export, on either side: puls-export copies the body straight through to the file.

A failure once the body is on the wire aborts the connection rather than closing a short file cleanly, so a truncated export is always a visibly failed download (curl: (18) transfer closed, puls-export: the download stopped early) and never a file that quietly stops halfway. The same goes for the export's own limits: it ends after 30 minutes, a client that stops reading for a minute is dropped, and a server shutdown past its grace cuts it off. Everything that can be rejected — an unknown dataset or format, a missing filter, an identifier that has never been synced, a range over the cap — is checked before the first byte, and comes back as the usual JSON 400.

The response is an attachment named puls-<dataset>-<start>-<end>.<csv|jsonl>.

The puls-export CLI#

Build from the same checkout as your server. From the repository root, with Go 1.26 or newer:

mkdir -p "$HOME/.local/bin"
(cd tools/puls-export && go build -o "$HOME/.local/bin/puls-export" .)
export PATH="$HOME/.local/bin:$PATH"
puls-export --version

The subshell keeps your terminal at the repository root. Add that bin directory to your shell's PATH for later sessions, or invoke the binary by its full path.

It is a thin client: it builds the query, sends the bearer token, and copies the response through. Everything the server can reject is left to the server and its message is printed verbatim, so the binary cannot drift out of step with the endpoint.

FlagDefault
--url$PULS_API_URL, else http://127.0.0.1:8081product API base URL
--token$PULS_API_TOKENbearer token, from server/.env
--dataset—required
--formatcsvcsv or jsonl
--start, --end—YYYY-MM-DD or epoch milliseconds; the range is half-open
--types, --type, --activity-type—the per-dataset filters above
--time-zone$PULS_TIME_ZONE, else UTCthe zone a YYYY-MM-DD bound is read in
--user$PULS_USER_ID, else nonethe user to export; none leaves it to the server's default
-ostandard outputwrite to this file
--versionprint the version and exit

--start 2026-01-01 --end 2026-02-01 is the whole of January. Exit status is 0 on success, 2 for a mistake in the command line, 1 for a failed download; the output file named by -o is created only once the server has answered 200, so a rejected request never truncates the previous export. A 403 is explained the way a 401 is: the server only exports its PULS_USER_ID unless it runs with PULS_MULTI_USER=true.

On-device export (no server)#

Open Export in the app to create files directly from HealthKit. No server or account is required. Available history depends on your app access and Health permissions; see Free and Pro. Export uses isolated state and never advances your database sync.

In the app it is the Export tab, a builder. Pick the data types (raw samples), the aggregated types — the same Aggregates picker and type pages as Sync: each type starts with its daily total or average, and its page adds hourly, weekly, monthly or per-device values — a time range (last 30 days, 90 days, a year, all time, or your own start and end date), CSV, JSON or JSONL, a CSV layout, and whether to zip it. The draft starts from what Sync sends, as applied — the same raw types, aggregates and workout route/stream switches. What you change in it is this export's alone, never the sync's, and the sync start date in Settings plays no part.

The app’s custom End picker is the last included local day. To export all of September, select September 1 through September 30. The app converts that last day into the start of the following day internally: October 1 at local midnight. A selection reaching today exports through now.

When set, the manifest’s endDate and the engine’s boundary are exclusive, as are server API end and CLI --end (so the CLI uses September 1 through October 1 for the same month). Records are selected by start time before the boundary; activity-ring dates must precede its local day. Routes and streams follow the selected workouts. An aggregate bucket crossing the boundary is omitted rather than written as a partial value.

When it finishes, Share or Save to Files opens the iOS share sheet with every file of the export, manifest included. With Zip into one file on, it shares one puls-export-<yyyyMMdd-HHmmss>.zip holding a folder of that name with the same files, unchanged. iOS's own archiver (NSFileCoordinator) builds the zip after the files are written, so the manifest's byte counts are the uncompressed sizes. The app deletes its staged copy once the share sheet reports the files were handed over, when you tap Delete Export, when you start another export, and at every launch — so save the files somewhere before moving on.

Keep the app open and the phone unlocked while it runs: HealthKit cannot be read on a locked phone, and an export that runs into a lock finishes incomplete and says which types it could not read. Large histories can contain millions of records; start with a short range before exporting years.

The app offers CSV, JSON and JSONL. The server API above still offers CSV and JSONL. The detailed formats and CSV are not symmetrical:

JSONL preserves full details and is directly replayable. The file (puls-export-<yyyyMMdd-HHmmss>.jsonl) is a concatenation of Puls Sync Protocol batches exactly as they would have gone over the wire, uncompressed: each batch's header line, then its sample, route, series, aggregate and activity-summary lines. Nothing about it is export-specific — it is the format docs/protocol/schema/ specifies and the fixture corpus tests — so every field of every kind is there, and a file can be fed to a server later: split it before each header line (the only lines whose top-level object has a batchID), gzip each piece if you like, and POST it to /v1/batches with the bearer token and X-User-ID. Ingest is idempotent, so replaying into a server that already holds some of it is safe. Three things to know:

  • The user is an HTTP header on the wire, not part of the body, so the JSONL does not say whose data it is. The manifest (below) does.
  • There is no {"profile":…} line. The name, e-mail, date of birth and sex on that line are the app's settings rather than HealthKit data, and an export leaves them out of a file that is about to be shared.
  • HealthKit deletion records are silently ignored in every export format. Exports contain health data snapshots, rather than deletion history.

tools/protocol-check validates one batch per file, so split an export the same way before checking it.

JSON is one complete document with the same detailed data as JSONL. puls-export-<yyyyMMdd-HHmmss>.json is an array containing the exact JSONL objects in the same order: batch header, records, next batch header, records. It includes raw samples, aggregates, routes, streams, ECG traces, heartbeat series and time-zone/source/metadata fields when available. It omits the configured identity profile, like JSONL. The exporter writes it incrementally and closes the array only after the data is written; failed or cancelled exports are removed. The whole export is not buffered in memory.

For example, simplified record excerpts inside an array look like:

[
  {"batchID": "11111111-1111-4111-8111-111111111111", "sampleCount": 1},
  {"type": "HKQuantityTypeIdentifierHeartRate", "kind": "quantity", "value": 62, "unit": "count/min"},
  {"batchID": "22222222-2222-4222-8222-222222222222", "aggregateCount": 1},
  {"aggregate": {"type": "HKQuantityTypeIdentifierStepCount", "func": "sum", "value": 8421, "unit": "count"}}
]

The actual objects contain the full protocol fields. For receiver replay, iterate the array, write each object as one JSONL line, then split into batches at header objects exactly as described above. JSON is not posted directly to /v1/batches. Both detailed formats include a separate JSON manifest and can be packaged into a ZIP. CSV layout and detail controls apply only to CSV.

CSV is a flattened view. Its default layout writes one file per dataset that has rows (puls-export-<yyyyMMdd-HHmmss>-<dataset>.csv; a dataset with no rows gets no file). In the default layout without optional detail columns, datasets shared with the server use the same header row and order, epoch-millisecond instants, YYYY-MM-DD local days, canonical units, a null as an empty cell, a list comma-joined inside its quoted cell, lowercase UUIDs, floats without an exponent — and the same spreadsheet-formula caveat, because cells are written verbatim here too.

datasetOn the server too?Columns
samplesyestype, unit, uuid, start, end, value, label, source
workoutsyesuuid, activityType, start, end, durationS, distanceM, energyKcal, hasRoute, availableMetrics
activityyesdate, moveKcal, moveGoalKcal, exerciseMin, exerciseGoalMin, standHours, standGoalHours, moveMode, moveTimeMin, moveTimeGoalMin
state_of_mindyesuuid, date, timestamp, kind, valence, valenceClassification, labels, associations
aggregatesno — device onlytype, func, intervalValue, intervalUnit, deviceFilter, bucketStart, bucketEnd, value, unit
workout_routesno — device onlyworkoutUUID, t, lat, lon, alt, hAcc, vAcc, speed, course
workout_seriesno — device onlyworkoutUUID, type, unit, t, value
medication_dosesno — device onlyuuid, start, end, medication, medicationID, status, scheduledAt, doseQuantity, doseUnit, source

CSV layouts and optional details#

The Export tab's Layout picker appears only for CSV:

LayoutFiles and rows
Separate datasets (default)The dataset tables above; all selected quantity and category types share samples.csv.
One file per data typeOne …-type-<HealthKit identifier>.csv per type, containing its raw samples, aggregates and workout stream points. A dataset column distinguishes records; inapplicable cells are empty. Workout route points share the workout file. Activity rings, mood and medication doses each have their own type file.
One combined tableOne …-records.csv, with a dataset column followed by the union of CSV columns. Each source row remains one row; columns that do not apply are empty. This does not join, sum or deduplicate records. Use dataset and type when filtering; raw samples and aggregate values must not be added together.
Daily summaryOne …-daily-summary.csv with date,timeZone,bucketStart,bucketEnd, then one column per aggregate series. Requires at least one aggregate, all at a 1-day interval. Selected raw data and other datasets still get their ordinary dataset files.

Summary column names preserve the full series identity and unit, for example HKQuantityTypeIdentifierHeartRate|average|1|day|watch|count/min. Rows preserve exact bucket boundaries: series with different boundaries are not merged just because they share a date label. Missing values are empty, zero is 0, and completed buckets are ordered by start time. The summary does not compute new totals from raw samples. It pivots the same HealthKit aggregates. A summary is bounded to one million series/bucket entries; a larger selection is rejected with a suggestion to shorten the range or use dataset files.

More options is collapsed by default. Source and device details add sourceBundleID,sourceVersion,device to samples, workouts, mood entries and medication doses. Sample metadata adds a metadata column containing JSON (null when absent), with normal CSV quoting. Basic sample and medication source-name columns stay as before. These options do not change the JSONL protocol or include identity profile fields.

Split large tables is off by default. Choose 100,000 or 1,000,000 data rows per file. CSV files gain -part-0001, -part-0002, etc.; every part repeats its header. Splitting counts complete records, including a cell with quoted newlines as one record. It applies to every CSV layout, including summaries. It does not split JSON or JSONL. ZIP remains independent of layout and splitting.

Illustrative CSV records (epoch milliseconds, UTC bucket):

type,unit,uuid,start,end,value,label,source
HKCategoryTypeIdentifierSleepAnalysis,,11111111-1111-4111-8111-111111111111,1767225600000,1767229200000,3,asleepCore,Apple Watch
type,func,intervalValue,intervalUnit,deviceFilter,bucketStart,bucketEnd,value,unit
HKQuantityTypeIdentifierStepCount,sum,1,day,all,1767225600000,1767312000000,8421,count

JSONL is a sequence of independent JSON objects, one per line, whereas a JSON file is one complete JSON document (often an array). The on-device JSONL is also framed as protocol batches, with headers between groups of records; it is not a single JSON array. JSONL supports streaming without loading the whole export. The manifest is an ordinary JSON document in either format.

The device-only files use the wire format's own keys, in wire order, one row per bucket, GPS fix, stream datapoint or dose. How the two sides differ:

  • samples holds every selected quantity and category type in one file, in the default layout, where the server exports one type per request. The app fills label with the bundled knowledge base's category enum name when known (for example asleepCore); unknown values leave it empty. The server obtains its labels from category_labels. A category sample's value is always HealthKit's raw integer on both sides.
  • daily_metrics and sleep do not exist on the device. Both are views the server computes over what it has stored (per-day totals deduplicated across devices; nights assembled from sleep-stage samples). The device's counterpart to daily_metrics is aggregates, the statistics HealthKit itself computes for whatever aggregate series are configured; sleep stages are rows of samples.
  • On-device local days are the phone's. activity.date and state_of_mind.date use the phone's time zone. The server preserves ring dates and recorded daily aggregate dates; raw records and mood entries are grouped in the account reporting zone. Travel can therefore produce different local-day labels between an on-device export and a server raw-data export.
  • What CSV leaves out. Unless enabled under More options, metadata, device, source bundle and version. Per-sample time-zone context and a workout's statistics, events and sub-activities have no column. ECG voltage traces, beat-to-beat heartbeat series have no file at all; the result the package returns counts them so the app can say so and suggest JSON or JSONL.

All three formats come with puls-export-<yyyyMMdd-HHmmss>-manifest.json: the user ID, the device ID, the requested range (startDate, null = all time; endDate, null = now), the format, the protocol schemaVersion and the app version, the time zone local days were computed in, the exported types and aggregates (each series as type|func|intervalValue|intervalUnit|deviceFilter), and per-file and per-dataset row counts. csvOptions records the CSV layout, detail switches and split limit. Per-file rows counts actual data rows in that file or part; per-type files also record their type. Dataset counts retain the original record/bucket counts (a summary row can contain several aggregate buckets). warnings records extraction caveats for ordinary exports as well as requests. complete describes extraction; check notRepresented for CSV omissions and warnings for missing optional details, even when extraction completed. Its main job is "complete": false with a failures list whenever a selected type could not be read to the end (for example, the phone locked part-way through) or a sample could not be converted to its canonical unit. A file that stops short looks exactly like the file of someone with less data; the manifest tells them apart. HealthKit can return no records when read access is denied, so complete: true does not prove that every expected record was accessible.

On iOS 27, Health access can be limited to recent history (the permission sheet's "Past 30 Days and Future Data", or Limited Access under Settings → Privacy & Security → Health). HealthKit then reads a type only from an earliest date on, and limitedHistory maps each type whose date falls after the export's start to that date (epoch milliseconds). The files hold nothing of those types before it, so that export is incomplete too; choose Full Access and export again to get the rest.

One caveat for replaying aggregate lines. Bucket boundaries are counted from the start of the series, so an export aligns each series to the grid the app's own sync uses, and a replay overwrites the server's buckets rather than adding a second, offset set. That alignment is exact for day, week and month buckets and for hour or minute intervals that divide a day evenly. It can be off for intervals that do not (5 hours, 7 minutes) and for a month series that starts on the 29th–31st; replay those with the aggregate lines filtered out and let the phone's next sync recompute them.

See also#

One-time requests#

The app can also generate a ZIP from a reviewed Data Request. These exports use an isolated engine, fresh random user and device IDs, and no configured identity profile. Their manifest additionally includes dataRequest and submissionID. They can be handed to the share sheet or sent directly to an explicitly approved HTTPS receiver. See Data Requests. Normal exports are unchanged.

Medication names and previously exported rows#

Medication exports resolve the HealthKit concept's display name from the medications the person authorized, including archived medications. If a concept cannot be resolved, medication is empty in CSV and null/omitted in JSON rather than an object description containing a memory address. Names are labels, not stable identifiers: two medications can share a name and a name can change. The dose unit is HealthKit's unit; count supplies no tablet/spoon-to-mass conversion. Do not infer milligrams or grams from it.

Older files containing <HKHealthConceptIdentifier: 0x…> can be replaced by a fresh export after installing the corrected app, within its Health permissions and export-history allowance. That address cannot recover medication identity. Existing self-hosted raw rows are insert-only: resetting anchors and syncing again does not update them. Preserve a backup and use a separately reviewed, user-scoped repair keyed on exact event UUIDs and timestamps from a corrected export using the historical repair tool. It defaults to a private reviewable plan and applies only that plan with explicit opt-in and original-value checks; there is no automatic rewrite or deletion.

medicationID is a random UUID alias for the opaque concept, persisted within this app installation and shared across sync and exports (including Data Requests). It groups equal concepts independently of names. It survives ordinary sync resets and receiver changes, but is not stable across devices, reinstalls or loss of the local registry. Clinical coding/strength metadata and cross-device identity remain outside this field's guarantee. Apple's public concept identifier has no exposed raw string, and count alone cannot establish a mass conversion.