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.
# 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
| Parameter | Required | Meaning |
|---|---|---|
format | yes | csv or jsonl |
dataset | yes | one of the six below |
start, end | yes | epoch milliseconds, [start, end) |
types | daily_metrics only | comma-separated HealthKit identifiers |
type | samples only | exactly one HealthKit identifier |
activityType | workouts only | keep one activity type |
user | no | the user to export, a UUID; absent means the server's PULS_USER_ID. Anyone else needs the server to run with PULS_MULTI_USER=true, or the answer is a 403 |
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: the same cap /v1/sleep/daily and
/v1/state-of-mind already apply, and deliberately stricter than
/v1/metrics/daily, /v1/activity/summary and /v1/workouts, which have no
range cap because a page is bounded by its page size. A file is bounded only
by its range, so it needs one. Over the cap is a 400 naming the limit, in
the shape the JSON endpoints use:
{"error": "range must not exceed 31 days"}. The cap above measures the
instant span; the day-grained datasets additionally reject a range that
touches more than 366 local calendar days, with their own message. Either way
it is a clean 400 before a single byte of the file.
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.
dataset | From | Columns |
|---|---|---|
daily_metrics | /v1/metrics/daily | identifier, unit, date, value |
samples | /v1/samples | type, unit, uuid, start, end, value, label, source |
workouts | /v1/workouts | uuid, activityType, start, end, durationS, distanceM, energyKcal, hasRoute, availableMetrics |
sleep | /v1/sleep/daily | date, start, end, inBedMinutes, asleepMinutes, stages.core, stages.deep, stages.rem, stages.unspecified, stages.awake, sources |
activity | /v1/activity/summary | date, moveKcal, moveGoalKcal, exerciseMin, exerciseGoalMin, standHours, standGoalHours, moveMode, moveTimeMin, moveTimeGoalMin |
state_of_mind | /v1/state-of-mind | uuid, date, timestamp, kind, valence, valenceClassification, labels, associations |
Values follow the same rules as the JSON endpoints — epoch milliseconds for
instants, YYYY-MM-DD local calendar days (in the server's PULS_TIME_ZONE)
for days, canonical units — with two format-specific conventions:
- A null is an empty CSV cell and an explicit
nullin 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.
Every column here is a number, a date, a UUID or a HealthKit identifier except
source, which is the display name of whatever app wrote the sample. 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. 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
go install github.com/PulsHealth/pulshealth/tools/puls-export@latest
# or, from a checkout — it is its own Go module, so build it from its own
# directory; there is no module at the repository root
cd tools/puls-export && go build -o puls-export .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.
| Flag | Default | |
|---|---|---|
--url | $PULS_API_URL, else http://127.0.0.1:8081 | product API base URL |
--token | $PULS_API_TOKEN | bearer token, from server/.env |
--dataset | — | required |
--format | csv | csv 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 UTC | the zone a YYYY-MM-DD bound is read in |
--user | $PULS_USER_ID, else none | the user to export; none leaves it to the server's default |
-o | standard output | write to this file |
--version | print 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.
See also
- The endpoint reference on the running server:
GET /docs, and the machine-readable/openapi.json. docs/database-guide.md— what the columns mean and which trap each dataset avoids.docs/ai.md— the same data through an MCP client or a ChatGPT Action.