PulsHealth
All documentation

Bulk export

Pulling a whole range of one dataset as streamed CSV or JSONL over GET /v1/export, with curl or the puls-export CLI.

View source on GitHubRendered from docs/export.md at build time.
On this page

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

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; 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.

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 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 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. 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.

FlagDefault
--url$PULS_API_URL, else http://127.0.0.1:8081product API base URL
--token$PULS_API_TOKENbearer token, from server/.env
--datasetrequired
--formatcsvcsv or jsonl
--start, --endYYYY-MM-DD or epoch milliseconds; the range is half-open
--types, --type, --activity-typethe 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.

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.

Found a gap?

This page is the repository file, rendered. Fix it there and the site follows.