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-key header (forwarded on every request, including the liveness probe).
  • v1 (required): GET /api/health (or /health as 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/health first. On 404 it retries once at GET /health. Implement either one (or both); the reference Data Hub exposes both.
  • Status accept-list. The response status field is matched case-insensitively, whitespace-trimmed, against ok, healthy, alive, ready, or up. Anything else (broken, error, down, starting, …) is rejected.
  • Auth forwarding. x-api-key is sent on liveness when configured. 401/403 classifies 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