A full backup carries the account's own mood tags, rated factors and tag groups, and the JSON import left them out: a mood entry imported into another account landed without every link to a tag the person had made. Before the entries are written, every key they or the file name is resolved once. A catalogue tag or one the account owns is used as it is; an own tag whose label the file states differently is kept, never overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a group). A tag the file defines and the account lacks is created in its group, the group created the same way, within the account's caps of 50 tags and 12 groups. A key another account of the server holds comes over under a key derived from the account and the file's key, so a second import of the same file finds the copy instead of minting another. The links are written through that resolution, own rated factors included, inside the entry's transaction; anything unresolved is still dropped and named. The import card names kept keys in all nine locales. The field-coverage guard now holds the backup's customMoodTags and customMoodTagCategories to the import schema. The round trip moves a backup to another server (the same seeded catalogue, none of the account's rows), imports it into a fresh account and compares the re-export, own tags, group and rated factor included; a second case imports into another account of the same server, and a third keeps a renamed own tag.
21 KiB
Data import
HealthLog has four ways to bring data in, all reachable from Settings → Export & Import:
- An Apple Health
export.zipupload — the full archive from the iPhone Health app, parsed server-side. Seeapple-health.mdfor the end-to-end walkthrough, the HealthKit type mapping, and the streaming / idempotency model. - A generic JSON import — a small, explicit payload of measurements and mood entries, useful for restoring a partial export or migrating from another tracker. This page documents that format.
- A CSV import — one measurement per row, for a spreadsheet or a meter / sensor export. Documented in section 3 below.
- A medication dose-history import — its own card in the same
panel, posting to
POST /api/medications/intake/dose-history-importwith its own?dryRun=1preview.
All paths skip rows that already exist, so re-running an import is safe — it merges rather than duplicating.
Documents (letters, lab reports, scans) from Paperless-ngx or Papra come in
through a document token and an import script instead; see
document-import.md.
Every import reports what it actually wrote. A file where nothing was written is shown as a failure, a file where some rows landed and some were refused is shown as a partial result, and the refused rows are grouped by reason so one bad column reads as one line rather than a scroll.
1. Apple Health export.zip
On the iPhone: Health app → profile picture → Export All Health
Data, then move the resulting export.zip somewhere you can reach
from a browser. In HealthLog open Settings → Export & Import and drop
the archive onto the Apple Health control (or click to pick it). The
upload streams to the server — a multi-gigabyte archive never buffers in
the browser — and a background job parses it while a progress indicator
polls for the imported / skipped counts.
Re-uploading the same archive resolves to the same job by content hash,
so it merges instead of creating duplicates. ECG CSVs and metric-typed
clinical records are imported too; see
apple-health.md for the details and the full
type mapping.
2. Generic JSON import
POST /api/import accepts a single JSON object with two optional
arrays:
{
"measurements": [/* … */],
"moodEntries": [/* … */]
}
Each array holds at most 10 000 entries and the file at most 16 MB.
The endpoint is rate-limited to 5 imports per hour per account.
Existing rows (matched on the type + timestamp uniqueness, or on
externalId for mood entries) are skipped and counted under skipped in
the response.
The app's own files go straight in: the JSON export (Export → JSON,
read inside its data envelope) and the portable full backup. Every field
either file writes for a reading or a mood entry comes back, apart from
the row ids, the source (every imported row is stored as IMPORT) and the
sync bookkeeping of the account the file came from. An empty field, null
in the backup and "" in the export, reads as absent, a backup's deletion
records are skipped and counted, and a reading in a display unit (lb, °F,
mmol/L) is converted to the stored one.
A mood entry's tags and rated factors are linked by key. A full backup
also carries the account's own tags, rated factors and tag groups, and the
import brings them along: a tag the importing account already has is used
as it is, and one it lacks is created (within the account's limit of 50
custom tags and 12 groups). The account's own tag is never overwritten: when
the file names it differently, the account's name stays and the response
lists the key in keptMoodTagKeys (keptMoodCategoryKeys for a group).
Moving a file between two accounts of one server works too; the tags come
over under keys of their own. A key the account cannot resolve at all is
dropped from that entry and named in droppedTagKeys or
droppedFactorKeys, and the rest of the entry lands.
Measurement entry
| Field | Required | Type | Notes |
|---|---|---|---|
type |
yes | enum | One of the MeasurementType values in the table below. |
value |
yes | number | Plausibility-checked per type; out-of-range values are rejected. |
unit |
yes | string | The canonical unit for the type (see the table). |
measuredAt |
yes | string | ISO-8601 datetime, e.g. 2026-05-01T08:00:00.000Z. |
source |
no | string | Free-text origin label, not stored. Imported rows are tagged IMPORT server-side regardless. |
notes |
no | string | Optional free text. |
glucoseContext |
no | enum | Only for BLOOD_GLUCOSE. See Glucose context. |
valueMin |
no | number | Lower bound of a range reading, such as an hourly heart-rate statistic. Not above valueMax. |
valueMax |
no | number | Upper bound of a range reading. |
sleepStage |
no | enum | A sleep row's stage: IN_BED, ASLEEP, AWAKE, CORE, DEEP or REM. |
rhythmClassification |
no | enum | The device's verdict on an ECG, a rhythm alert or a steadiness reading. |
deviceType |
no | string | The writer of the reading (watch, phone), 1–32 chars. |
externalId |
no | string | A source-stable id (1–120 chars). When present, a re-import upserts on (user, type, source, externalId) so an edit upstream is reflected rather than skipped. |
Glucose context
glucoseContext is one of FASTING, POSTPRANDIAL, RANDOM,
BEDTIME, and it is genuinely optional on a BLOOD_GLUCOSE row. Leave
it out (JSON) or leave the cell blank (CSV) and the reading is stored
with no context.
That is the normal case for a continuous sensor or meter export, which records a value and a timestamp per reading and classifies nothing. A reading with no context still counts everywhere a reading counts: the chart, the all-time summary, and the trailing-30-day clinical panel (time in range, GMI, CV%, estimated A1c) are all context-agnostic. It is left out only of the per-context breakdowns — the doctor report's fasting / post-meal rows, the matching FHIR observations, the per-context targets — because no classification was recorded and none is invented.
Setting the column on any type other than BLOOD_GLUCOSE is rejected.
Setting it to a value outside the four above is rejected. Entering a
reading by hand in the app still asks for a context, because at that
moment you know it.
Mood entry
| Field | Required | Type | Notes |
|---|---|---|---|
date |
yes | string | YYYY-MM-DD. |
mood |
yes | enum | One of SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG. |
score |
yes | integer | 1–5. |
tags |
no | string | Comma-separated tags. |
note |
no | string | The entry's note, up to 500 chars. |
a1–a5 |
no | integer | The five level-A values, 0–10. Without a1, pleasantness is derived from mood. |
loggedAt |
no | string | ISO-8601 datetime; defaults to the start of the given date in the default timezone. |
tz |
no | string | The IANA zone date is anchored to. |
factors |
no | array | Rated factors, [{ "key": "…", "rating": 1–5 }]; replace the entry's rated links. |
structuredTags |
no | array | Ticked tags by key; replace the entry's tag links. |
context |
no | object | The day context (work, contacts, leisure, an event, a note), as the backup writes it; replaces the entry's context. |
externalId |
no | string | A source-stable id (1–120 chars). When present, a re-import upserts on (user, source, externalId) so an edit upstream is reflected rather than skipped. |
MeasurementType values and canonical units
Storage is always in the canonical unit listed here; the UI converts for
display per the user's preference. Event-class types record a single
fired event (value is 1) and are normally produced by a device, not
hand-authored.
| Type | Canonical unit |
|---|---|
WEIGHT |
kg |
BLOOD_PRESSURE_SYS |
mmHg |
BLOOD_PRESSURE_DIA |
mmHg |
PULSE |
bpm |
RESTING_HEART_RATE |
bpm |
WALKING_HEART_RATE_AVERAGE |
bpm |
AVERAGE_HEART_RATE |
bpm |
MAX_HEART_RATE |
bpm |
CARDIO_RECOVERY |
bpm |
HEART_RATE_VARIABILITY |
ms (SDNN) |
HRV_RMSSD |
ms (RMSSD) |
RESPIRATORY_RATE |
count/min |
OXYGEN_SATURATION |
percent (0–100) |
BLOOD_GLUCOSE |
mg/dL |
BODY_FAT |
percent (0–100) |
FAT_MASS |
kg |
FAT_FREE_MASS |
kg |
LEAN_BODY_MASS |
kg |
MUSCLE_MASS |
kg |
BONE_MASS |
kg |
TOTAL_BODY_WATER |
kg |
VISCERAL_FAT |
rating |
BODY_MASS_INDEX |
kg/m² |
BODY_TEMPERATURE |
°C (core) |
SKIN_TEMPERATURE |
°C (dermal spot) |
WRIST_TEMPERATURE |
°C (overnight wrist) |
SLEEP_DURATION |
minutes |
SLEEP_NEED |
minutes |
SLEEP_PERFORMANCE |
percent (0–100) |
SLEEP_EFFICIENCY |
percent (0–100) |
SLEEP_CONSISTENCY |
percent (0–100) |
SLEEP_DISTURBANCE_COUNT |
count |
BREATHING_DISTURBANCES |
count |
ACTIVITY_STEPS |
count |
FLIGHTS_CLIMBED |
count |
WALKING_RUNNING_DISTANCE |
metres |
SIX_MINUTE_WALK_DISTANCE |
metres |
WALKING_STEP_LENGTH |
metres |
WALKING_SPEED |
m/s |
STAIR_ASCENT_SPEED |
m/s |
STAIR_DESCENT_SPEED |
m/s |
WALKING_STEADINESS |
percent (0–100) |
WALKING_ASYMMETRY |
percent (0–100) |
WALKING_DOUBLE_SUPPORT |
percent (0–100) |
ACTIVE_ENERGY_BURNED |
kcal |
ENERGY_EXPENDITURE_KJ |
kJ |
VO2_MAX |
mL/(kg·min) |
PULSE_WAVE_VELOCITY |
m/s |
VASCULAR_AGE |
years |
FALL_COUNT |
count |
AUDIO_EXPOSURE_ENV |
dBA |
AUDIO_EXPOSURE_HEADPHONE |
dBA |
TIME_IN_DAYLIGHT |
minutes |
RECOVERY_SCORE |
score (0–100) |
STRESS_SCORE |
score (0–100) |
STRAIN_SCORE |
score (0–100) |
DAY_STRAIN |
score (0–21) |
WORKOUT_STRAIN |
score (0–21) |
AUDIO_EXPOSURE_EVENT |
count (event) |
IRREGULAR_RHYTHM_NOTIFICATION |
event |
HIGH_HEART_RATE_EVENT |
event |
LOW_HEART_RATE_EVENT |
event |
WALKING_STEADINESS_EVENT |
event |
BREATHING_DISTURBANCE_EVENT |
event |
The table is a curated subset and lags the enum by a few
device-derived types. The live, machine-readable list of writable
types and their units is served by GET /api/meta/capabilities under
ingest.quantityTypes and is the authoritative source.
Worked example
The Download example button in Settings → Export & Import mints exactly this payload, which is a valid import body:
{
"measurements": [
{
"type": "WEIGHT",
"value": 80.5,
"unit": "kg",
"measuredAt": "2026-05-01T08:00:00.000Z",
"source": "manual",
"notes": "morning"
},
{
"type": "BLOOD_PRESSURE_SYS",
"value": 120,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
},
{
"type": "BLOOD_PRESSURE_DIA",
"value": 80,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
}
],
"moodEntries": [
{
"date": "2026-05-01",
"mood": "GUT",
"score": 4,
"tags": "work,exercise"
}
]
}
The response is a count envelope:
{
"data": { "measurements": 3, "moodEntries": 1, "skipped": 0 }
}
3. CSV import
POST /api/import/csv takes the raw file as text/csv — one measurement
per row, no JSON wrapper. The CSV file control in
Settings → Export & Import posts to it, and Download example
mints a file with the right header.
A header row is required; column order does not matter and header matching is case-insensitive.
| Column | Required | Notes |
|---|---|---|
type |
yes | A MeasurementType from the table above. |
value |
yes | A number, in the row's unit. |
unit |
yes | The canonical unit, or a recognised alias: mmol/L → mg/dL, lb → kg, in → cm, °F → °C. |
measuredAt |
yes | ISO-8601 with an explicit offset (Z or ±HH:MM, and ±HHMM is accepted). No offset, no import. |
glucoseContext |
no | Only for BLOOD_GLUCOSE; blank means no context. See Glucose context. |
notes |
no | Free text, up to 200 characters. |
externalId |
no | A source-stable id. With it, a re-upload updates the same row even when the value changed; without it, an identical re-upload is skipped via the natural key (user, type, measuredAt, source). |
A sensor export therefore needs no glucose column at all:
type,value,unit,measuredAt,glucoseContext,notes,externalId
BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,,sensor-1
BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:30:00+1100,,,sensor-2
Limits: 16 MB per upload, 10 000 valid rows per import, 5 imports per
hour (shared with the JSON import). ?dryRun=1 validates and previews
without writing anything — the Preview button uses it.
The response carries per-row outcomes alongside the counts, and the card renders the counts, not the status code: rows written and none skipped is a success, a mixed result is a warning, and nothing written is a failure with the reasons grouped by count.
Converting a CSV into the import JSON
The CSV import above covers measurements. Mood entries have no CSV route, so a mood history still goes through the JSON payload.
A spreadsheet export is the most common starting point. The shape is small enough to convert by hand for a few rows, or with a one-off script for many. The steps are the same either way:
- One CSV per concept. Keep measurements and mood in separate
sheets — they map to the two arrays. A blood-pressure reading is two
measurement rows (
BLOOD_PRESSURE_SYSandBLOOD_PRESSURE_DIA) that share ameasuredAt. - Pick the
type. Map each CSV column to aMeasurementTypefrom the table above. A "Weight (kg)" column becomestype: "WEIGHT",unit: "kg". - Normalise the unit. Convert each value into the canonical unit
before writing it — e.g. pounds → kg, hours of sleep → minutes,
mmol/Lglucose →mg/dL. The importer rejects values outside the plausible range for the type, which catches most unit mistakes. - Format the timestamp. Turn the CSV date/time column into an
ISO-8601
measuredAt(mood uses aYYYY-MM-DDdate). Include the timezone offset (Zfor UTC) so the reading lands on the right day. - Emit the two arrays. Collect the rows into
measurementsandmoodEntriesas shown above and paste the result into the JSON import control, or upload it as a.jsonfile. Keep each array under 10 000 entries; split a larger history across runs (5 per hour).
Because the import is idempotent on the unique constraints, you can re-run a corrected file over a partial one without creating duplicates.