HealthSave API Contract
Updated May 2026 · iOS app version 1.5
HealthSave can sync Apple Health data to any self-hosted server that implements this contract. Configure the app with the base server URL only, such as https://health.example.com; HealthSave appends the endpoint paths below. The reference open-source backend is health-data-hub, but any server that speaks this contract works — including community implementations such as health-data-to-mqtt.
- Base URL: enter only the origin or base path in the app.
- Authentication: optional
x-api-keyheader (forwarded on every request, including the liveness probe). - v1 (required):
GET /api/health(or/healthas a fallback),GET /api/apple/status,POST /api/apple/batch. - v2 (optional): sync-receipt endpoints under
/api/v2/sync/let the app surface a "delivery receipt" confidence tier when present. A v1-only server still syncs cleanly — it just doesn't unlock that tier.
Versioning
The v1 surface is frozen: HealthSave 1.4 and 1.5 clients depend on the exact response shape and field names below. The v2 namespace under /api/v2/ is the additive opt-in receipt layer introduced with the 1.5 cycle. New endpoints land in v2 so v1 stays stable for installed users; HealthSave reads v2 endpoints only when present and gracefully ignores them when they aren't.
Endpoints
| Method | Path | Tier | Purpose |
|---|---|---|---|
GET |
/api/health |
v1 | App-friendly liveness probe. iOS calls this first. |
GET |
/health |
v1 | Minimal liveness check. iOS retries here when /api/health returns 404. |
POST |
/api/apple/batch |
v1 | Receives one HealthKit metric batch. |
GET |
/api/apple/status |
v1 | Returns per-metric server counts for the sync status screen. |
GET |
/api/v2/setup/diagnostics |
v2 (optional) | Unauthenticated, no health data. Helps the user tell a HealthSave-compatible API apart from a wrong port (Grafana, Homepage, etc.). |
GET |
/api/v2/sync/runs/latest |
v2 (optional) | Returns the most recent sync run's delivery receipt. |
GET |
/api/v2/sync/runs/{sync_run_id} |
v2 (optional) | Per-run delivery receipt. The iOS app uses this to mark a run as "delivery-receipt verified." |
GET |
/api/v2/sync/coverage |
v2 (optional) | Metric-level receipt coverage and destination sample coverage for operators. |
Authentication
If your backend requires an API key, HealthSave sends it with protected requests as an x-api-key header.
x-api-key: your-api-key
As of iOS 1.5 the key is also forwarded on the liveness probe (/api/health or its /health fallback). Servers that protect liveness as defense in depth work without special handling: a 401 or 403 on the liveness path is treated as a key problem (the user sees actionable copy), not as "this server isn't HealthSave-compatible."
Health Checks
HealthSave 1.5 broadened the liveness probe so third-party servers don't fail on plausible variations the v1 contract never pinned. The tolerances:
- Endpoint discovery. iOS calls
GET /api/healthfirst. On404it retries once atGET /health. Implement either one (or both); the reference Data Hub exposes both. - Status accept-list. The response
statusfield is matched case-insensitively, whitespace-trimmed, againstok,healthy,alive,ready, orup. Anything else (broken,error,down,starting, …) is rejected. - Auth forwarding.
x-api-keyis sent on liveness when configured.401/403classifies as auth-failed (actionable), not as not-HealthSave. - Timeout. 10 seconds.
A minimal successful response is just:
{
"status": "ok"
}
Batch Ingest
POST /api/apple/batch receives one metric at a time. A successful 2xx response is enough for the app; the response body below matches the reference backend.
{
"metric": "heart_rate",
"batch_index": 0,
"total_batches": 1,
"samples": [
{
"date": "2026-04-10T12:00:00Z",
"qty": 72,
"source": "Apple Watch"
}
]
}
{
"status": "processed",
"metric": "heart_rate",
"batch": 0,
"total_batches": 1,
"records": 1
}
Quantity Samples
Quantity-like metrics use date, qty, and source. Some compatible servers also accept unit, but HealthSave does not require it on every sample.
Sleep Samples
{
"metric": "sleep_analysis",
"samples": [
{
"startDate": "2026-04-09T23:20:00Z",
"endDate": "2026-04-10T06:45:00Z",
"value": 3,
"source": "Apple Watch"
}
]
}
Workout Samples
{
"metric": "workouts",
"samples": [
{
"name": "Running",
"start": "2026-04-10T07:00:00Z",
"end": "2026-04-10T07:45:00Z",
"duration": 2700,
"source": "Apple Watch",
"activeEnergy": 420,
"distance": 6500,
"avgHeartRate": 145,
"maxHeartRate": 178,
"heartRateData": [
{"date": "2026-04-10T07:01:00Z", "qty": 132}
],
"route": [
{
"latitude": 41.01,
"longitude": 28.97,
"altitude": 42.0,
"speed": 2.8,
"timestamp": "2026-04-10T07:01:00Z"
}
]
}
]
}
Activity Summary Samples
{
"metric": "activity_summaries",
"samples": [
{
"date": "2026-04-10",
"activeEnergyBurned": 540,
"activeEnergyBurnedGoal": 600,
"appleExerciseTime": 42,
"appleExerciseTimeGoal": 30,
"appleStandHours": 12,
"appleStandHoursGoal": 12
}
]
}
Blood Pressure Correlations
Blood pressure batches use metric: "blood_pressure" at the batch level. Each sample carries its own inner metric value so systolic and diastolic readings remain distinct.
{
"metric": "blood_pressure",
"samples": [
{
"metric": "blood_pressure_systolic",
"date": "2026-04-10T09:00:00Z",
"qty": 120,
"source": "Blood Pressure Monitor"
},
{
"metric": "blood_pressure_diastolic",
"date": "2026-04-10T09:00:00Z",
"qty": 80,
"source": "Blood Pressure Monitor"
}
]
}
Category Event Samples
Category events use date, qty, source, and, when available, endDate plus rawValue. For duration-based events, qty is the duration in seconds and rawValue keeps the raw HealthKit category value. For instant events, qty is the raw category value.
{
"metric": "mindful_session",
"samples": [
{
"date": "2026-04-10T08:00:00Z",
"endDate": "2026-04-10T08:15:00Z",
"qty": 900,
"rawValue": 0,
"source": "Apple Watch"
}
]
}
ECG Samples
ECG batches can include start, end, classification, numberOfVoltageMeasurements, samplingFrequency, and averageHeartRate. Store them if your backend has an ECG model, or accept the batch for compatibility.
Status Contract
GET /api/apple/status must return a flat JSON object. Every top-level key is treated as a metric name, and every top-level value should be an object with at least count. oldest and newest are optional.
{
"heart_rate": {
"count": 123,
"oldest": "2026-04-01T08:00:00Z",
"newest": "2026-04-10T12:00:00Z"
},
"heart_rate_variability": {
"count": 45,
"oldest": "2026-04-01T08:00:00Z",
"newest": "2026-04-10T12:00:00Z"
},
"quantity_samples": {
"count": 900
}
}
Do not return {"status":"ok","counts":{...}} from this endpoint. That wrapped shape makes status look like a metric and makes counts look like one metric instead of the metric dictionary.
Sync Receipt Evidence (v2, optional)
The v2 sync-receipt surface lets HealthSave move from "we uploaded the batch" to "the destination confirmed it received the batch" — a higher confidence tier the in-app Server Sync screen surfaces as "delivery receipt." None of these endpoints are required; HealthSave reads them when present and falls back gracefully when they aren't.
Request headers HealthSave sends
From iOS 1.5 onward, every POST /api/apple/batch call carries optional X-HealthSave-* headers a compatible server can record to materialize receipts:
Idempotency-Key: <batch UUID>
X-HealthSave-Sync-Run-ID: <run UUID>
X-HealthSave-Batch-ID: <batch UUID>
X-HealthSave-Payload-Hash: <sha256>
X-HealthSave-Metric: heart_rate
X-HealthSave-Batch-Index: 0
X-HealthSave-Total-Batches: 1
X-HealthSave-Sync-Mode: incremental
X-HealthSave-Anchor-Present: true
X-HealthSave-Lower-Bound-Reason: anchor
X-HealthSave-Full-Export: false
X-HealthSave-Query-Lower-Bound: 2026-04-10T00:00:00Z
X-HealthSave-Sample-Min-Time: 2026-04-10T07:00:00Z
X-HealthSave-Sample-Max-Time: 2026-04-10T07:13:00Z
Servers that don't care about receipts can ignore these headers entirely. Servers that want to expose /api/v2/sync/runs/{sync_run_id} should persist them — see the reference API.md for the full per-header semantics and response shapes.
Idempotency contract
If the same Idempotency-Key arrives with a different X-HealthSave-Payload-Hash, a compliant server should reject the request with 409 Conflict rather than ingest the conflicting payload. This protects against retry-induced data corruption when the network resends a batch after a partial failure.
Additive batch response fields
The v1 success response shape {"status": "processed", "metric": "...", "batch": 0, "total_batches": 1, "records": N} stays unchanged. Servers may add the receipt fields below; iOS 1.5 reads them when present:
{
"status": "processed",
"metric": "heart_rate",
"batch": 0,
"total_batches": 1,
"records": 1,
"receipt_id": "run_01HY:heart_rate:0",
"sync_run_id": "run_01HY",
"batch_id": "...",
"idempotency_key": "...",
"records_received": 1,
"records_accepted": 1,
"records_rejected": 0,
"verification_level": "delivery_receipt",
"sample_window": {
"min_sample_time": "2026-04-10T07:00:00Z",
"max_sample_time": "2026-04-10T07:13:00Z"
},
"per_metric": {
"heart_rate": {
"received": 1,
"accepted": 1,
"rejected": 0,
"sample_window": { "min_sample_time": "...", "max_sample_time": "..." }
}
}
}
Metric Catalog
HealthSave accepts compatible backends that store unknown metrics generically. The names below are the source-derived catalog the app can send.
Heart & Cardiovascular
heart_rate, resting_heart_rate, walking_heart_rate_average, heart_rate_variability, heart_rate_recovery, atrial_fibrillation_burden, vo2_max, oxygen_saturation, respiratory_rate, peripheral_perfusion_index
Blood Pressure & Metabolic
blood_pressure, blood_pressure_systolic, blood_pressure_diastolic, blood_glucose, insulin_delivery, blood_alcohol_content, number_of_alcoholic_beverages
Activity & Movement
step_count, distance_walking_running, distance_cycling, distance_swimming, distance_wheelchair, distance_downhill_snow_sports, distance_cross_country_skiing, distance_paddle_sports, distance_rowing, distance_skating_sports, flights_climbed, swimming_stroke_count, push_count, nike_fuel, apple_exercise_time, apple_stand_time, apple_move_time, active_energy_burned, basal_energy_burned, number_of_times_fallen
Mobility, Running & Cycling
walking_speed, walking_step_length, walking_asymmetry, walking_double_support, stair_ascent_speed, stair_descent_speed, apple_walking_steadiness, six_minute_walk_test_distance, running_power, running_speed, running_stride_length, running_vertical_oscillation, running_ground_contact_time, cycling_speed, cycling_power, cycling_cadence, cycling_functional_threshold_power
Sport Speeds, Effort & Body
cross_country_skiing_speed, paddle_sports_speed, rowing_speed, physical_effort, workout_effort_score, estimated_workout_effort_score, body_temperature, wrist_temperature, basal_body_temperature, body_mass, body_fat_percentage, bmi, lean_body_mass, height, waist_circumference, electrodermal_activity
Respiratory, Sleep, Environment & Water
forced_expiratory_volume_1, forced_vital_capacity, peak_expiratory_flow_rate, inhaler_usage, sleep_analysis, sleeping_breathing_disturbances, environmental_audio_exposure, headphone_audio_exposure, environmental_sound_reduction, uv_exposure, time_in_daylight, underwater_depth, water_temperature
Nutrition
dietary_energy_consumed, dietary_protein, dietary_fat_total, dietary_fat_saturated, dietary_fat_monounsaturated, dietary_fat_polyunsaturated, dietary_carbohydrates, dietary_sugar, dietary_fiber, dietary_cholesterol, dietary_sodium, dietary_potassium, dietary_calcium, dietary_iron, dietary_magnesium, dietary_phosphorus, dietary_zinc, dietary_manganese, dietary_copper, dietary_selenium, dietary_chromium, dietary_molybdenum, dietary_chloride, dietary_biotin, dietary_vitamin_a, dietary_vitamin_b6, dietary_vitamin_b12, dietary_vitamin_c, dietary_vitamin_d, dietary_vitamin_e, dietary_vitamin_k, dietary_folate, dietary_niacin, dietary_pantothenic_acid, dietary_riboflavin, dietary_thiamin, dietary_iodine, dietary_water, dietary_caffeine
Structured Types
workouts, activity_summaries, ecg
Category Events
high_heart_rate_event, low_heart_rate_event, irregular_heart_rhythm_event, low_cardio_fitness_event, mindful_session, handwashing_event, toothbrushing_event, environmental_audio_exposure_event, headphone_audio_exposure_event, apple_walking_steadiness_event
Reproductive Health & Symptoms
menstrual_flow, intermenstrual_bleeding, ovulation_test_result, cervical_mucus_quality, sexual_activity, contraceptive, pregnancy, pregnancy_test_result, lactation, progesterone_test_result, infrequent_menstrual_cycles, irregular_menstrual_cycles, persistent_intermenstrual_bleeding, prolonged_menstrual_periods, bleeding_after_pregnancy, bleeding_during_pregnancy, abdominal_cramps, acne, appetite_changes, generalized_body_ache, bloating, breast_pain, chest_tightness_or_pain, chills, constipation, coughing, diarrhea, dizziness, fainting, fatigue, fever, headache, heartburn, hot_flashes, lower_back_pain, loss_of_smell, loss_of_taste, mood_changes, nausea, pelvic_pain, rapid_pounding_or_fluttering_heartbeat, runny_nose, shortness_of_breath, sinus_congestion, skipped_heartbeat, sleep_changes, sore_throat, vomiting, wheezing, bladder_incontinence, dry_skin, hair_loss, vaginal_dryness, memory_lapse, night_sweats, sleep_apnea_event