Get Apple Health data into Grafana
Why Grafana cannot be the destination
Grafana cannot be the destination you enter in the app. Grafana does not accept data — it reads it from a data source. There is no address you deliver a dashboard to.
So the path has a piece in the middle:
app ──POST──▶ your receiver ──▶ database ◀──reads── Grafana
That middle piece is a script of about thirty lines (the example is below): it accepts the POST and writes the values into a table. Grafana reads from that table.
If you don't want to run a database, take the file export instead and work in a spreadsheet app such as Excel or Numbers.
The table
A single table in long format is enough — one value per row. The key is metric + statistic + day, for a reason given further down:
CREATE TABLE readings ( day date NOT NULL, ts timestamptz NOT NULL, metric text NOT NULL, stat text NOT NULL, value double precision NOT NULL, unit text, PRIMARY KEY (metric, stat, day) );
stat records what the value means: qty for most metrics, min/avg/max for heart rate, asleep/deep/rem/core/awake for sleep. That way all three kinds of data points fit the same columns.
The receiver
This example accepts the POST, understands all three kinds of data points and writes by updating. PHP with PDO, here against PostgreSQL — SQLite or MySQL work just as well; only the connection string and the ON CONFLICT clause change.
<?php
$doc = json_decode(file_get_contents('php://input'), true);
if (!is_array($doc['data']['metrics'] ?? null)) { http_response_code(400); exit('bad json'); }
$db = new PDO('pgsql:host=localhost;dbname=health', 'user', 'pw',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$up = $db->prepare(
'INSERT INTO readings (day, ts, metric, stat, value, unit) VALUES (?,?,?,?,?,?)
ON CONFLICT (metric, stat, day) DO UPDATE SET value = EXCLUDED.value, ts = EXCLUDED.ts');
// field in the point => stat value in the table. totalSleep is left out on purpose:
// it always carries the same value as asleep.
$STATS = ['qty'=>'qty', 'Min'=>'min', 'Avg'=>'avg', 'Max'=>'max',
'asleep'=>'asleep', 'deep'=>'deep', 'rem'=>'rem', 'core'=>'core', 'awake'=>'awake'];
foreach ($doc['data']['metrics'] as $m) {
foreach ($m['data'] as $p) {
$t = DateTime::createFromFormat('Y-m-d H:i:s O', $p['date']); // offset has NO colon
if (!$t) continue;
$day = substr($p['date'], 0, 10);
foreach ($STATS as $field => $stat) {
if (isset($p[$field])) {
$up->execute([$day, $t->format('c'), $m['name'], $stat, (float)$p[$field], $m['units']]);
}
}
}
}
http_response_code(200);
echo 'ok';
Enter this file's address in the app under "Senden" (send) → destination. It has to be reachable from the outside over https:// — the app also sends while you're away from home. How to protect it is covered in Set up a destination: the app can send a header you name yourself, which you check against a value at the top.
Why updating, not appending
Appending instead of updating is a common mistake in self-written receivers.
The same days arrive repeatedly. Every run includes the last three days — deliberately, so gaps close by themselves if your server was ever away. The same day therefore reaches you many times over, usually with an identical value, occasionally with a corrected one. How often is not fixed: an automation and an explicit send ("Jetzt senden") trigger a run at any time — your receiver has to tolerate every repeat.
For heart_rate and step_count that window is shorter: one day, because that is where nearly all raw samples sit. If your server was away for longer than a day, those two series do not fetch the older days by themselves — tap "Jetzt senden" (send now) in the app once, which reads three days.
Delivery is at-least-once. If your server answers too slowly (the limit is 90 seconds) or the reply is lost in transit, the app repeats the same delivery later.
Write a plain INSERT and after a week you have every day three or four times over, and a dashboard that sums steps which only happened once. The primary key above and the ON CONFLICT clause take care of it.
Why the key is on the day and not the timestamp: daily values carry 12:00 local time, whereas sleep points carry the real end of the night. If a night is corrected afterwards (e.g. by your watch or ring), the timestamp moves — the day does not. Keyed on the day, the night is updated rather than duplicated.
The data source in Grafana
PostgreSQL and MySQL ship with Grafana — add a data source and enter the credentials. SQLite needs an extra plugin; if you're free to choose, take PostgreSQL.
A time-series query then looks like this:
SELECT ts AS "time", value FROM readings WHERE metric = 'resting_heart_rate' AND stat = 'qty' AND $__timeFilter(ts) ORDER BY ts;
For sleep use metric = 'sleep_analysis' and stat = 'asleep' — that value is in hours. For heart rate, min, avg and max give you three series for one panel.
Three pitfalls
The timestamp is not ISO 8601. The offset carries no colon (2026-08-02 12:00:00 +0200). Strict parsers choke, which is why the example above uses 'Y-m-d H:i:s O' rather than anything automatic.
Percentages are already converted. SpO₂ arrives as 97.4 with unit %, not 0.974. Multiply by 100 again and you get 9,740 %.
A missing field is not zero. Nights without real sleep stages omit deep, rem and core entirely. The isset() in the example matters: store missing stages as 0 and your dashboard shows nights without deep sleep that never happened.
What the app cannot see
The moment your receiver answers 200, the delivery counts as delivered — even if your INSERT fails afterwards. So answer only once you have actually written. Whatever happens after the 2xx does not appear in the app's log.
The full description of what arrives — all twelve metrics, all fields, all units — is in the format reference.