Your Apple Watch records your heart rate every few minutes, every step, and every sleep stage, all night. That data is sitting on your iPhone right now.
Now try to get it out. Automatically, every day, into something you control: a spreadsheet, a dashboard, an AI assistant.
There's no web API. HealthKit only works inside an iOS app. The apps that export Health data are mostly paid or subscriptions. And the one write-up I could find on doing it with Shortcuts sits behind a paywall.
So I built it with what's already on the phone: one free Shortcut + one free Cloudflare Worker. It's running on my phone now. Below is the whole pipeline, the code, and the four traps that made it fail silently, so you can skip the part where I stared at an empty database wondering why.
What you'll have at the end
| What | Details |
|---|---|
| Data | Steps, heart rate, sleep stages (Core, Deep, REM, Awake), as raw timestamped samples |
| Where | Your own Cloudflare Worker + D1 (SQLite) database |
| How often | Every time an automation fires (I use twice a day) |
| Output |
GET /days → daily totals as JSON |
| Cost | ₹0. Shortcuts is built in; Cloudflare's free plan allows 100,000 requests a day. You'll use about 2 |
| Apps to install | None |
Before you start: Health data is sensitive. In this setup it lives on your Cloudflare account, protected by a secret token you choose. Don't post your Worker URL or token anywhere, and don't send data types you don't need.
Part 1: the receiver (10 minutes)
We start with the server, so the Shortcut has somewhere to send to and you can test each step.
You need a free Cloudflare account and Node.js on your computer.
1. Create the project
mkdir health-receiver && cd health-receiver
npx wrangler login # opens the browser once
npx wrangler d1 create health # prints a database_id, copy it
2. wrangler.toml
name = "health-receiver"
main = "src/index.js"
compatibility_date = "2026-10-01"
[[d1_databases]]
binding = "DB"
database_name = "health"
database_id = "PASTE-THE-ID-FROM-STEP-1"
3. schema.sql
CREATE TABLE IF NOT EXISTS samples(
metric TEXT NOT NULL, ts INTEGER NOT NULL, end_ts INTEGER, value REAL NOT NULL, stage TEXT NOT NULL DEFAULT '',
UNIQUE(metric, ts, value, stage)
);
That UNIQUE line is the most important line in the project. Remember it for trap #2.
4. src/index.js
// Apple Health → Shortcut → this Worker → D1. Free tier is plenty for one person.
const OFFSET_MIN = 330; // your UTC offset in minutes (IST = +5:30 = 330)
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
// "2026-10-08T07:12:00+05:30 | 52" or "start | end | Core"
function parseLines(text) {
return String(text || "").split("\n").map(l => l.split("|").map(s => s.trim())).filter(p => p[0]);
}
const ms = s => { const t = Date.parse(s); return Number.isNaN(t) ? null : t; };
const localDay = t => new Date(t + OFFSET_MIN * 60000).toISOString().slice(0, 10);
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (req.headers.get("x-health-token") !== env.HEALTH_TOKEN) return json({ ok: false, error: "unauthorised" }, 401);
if (url.pathname === "/health" && req.method === "POST") {
let body; try { body = await req.json(); } catch { return json({ ok: false, error: "body must be JSON" }, 400); }
const rows = [], skipped = [];
for (const [metric, field] of [["steps", "steps"], ["heart_rate", "heart rate"]]) {
for (const [ts, v] of parseLines(body[field])) {
const t = ms(ts), n = Number(v);
if (t === null || !Number.isFinite(n)) { skipped.push(`${field}: ${ts}`); continue; }
rows.push([metric, t, null, n, ""]);
}
}
for (const p of parseLines(body.sleep)) {
const [a, b, stage] = p.length >= 3 ? p : [p[0], null, p[1]];
const t = ms(a), e = b ? ms(b) : null;
if (t === null || e === null) { skipped.push(`sleep without an end time: ${a}`); continue; } // can't know how long it lasted
rows.push(["sleep", t, e, 0, stage || ""]);
}
// UNIQUE(metric, ts, value, stage) makes re-sending the same samples harmless: overlap = free backfill
const stmt = env.DB.prepare("INSERT OR IGNORE INTO samples(metric, ts, end_ts, value, stage) VALUES(?, ?, ?, ?, ?)");
for (let i = 0; i < rows.length; i += 50) await env.DB.batch(rows.slice(i, i + 50).map(r => stmt.bind(...r)));
return json({ ok: true, received: rows.length, skipped });
}
if (url.pathname === "/days" && req.method === "GET") {
const { results } = await env.DB.prepare("SELECT metric, ts, end_ts, value, stage FROM samples ORDER BY ts").all();
const days = {};
const d = k => (days[k] ||= { steps: 0, hr: [], sleep_min: 0 });
for (const r of results) {
if (r.metric === "steps") d(localDay(r.ts)).steps += r.value;
if (r.metric === "heart_rate") d(localDay(r.ts)).hr.push(r.value);
if (r.metric === "sleep" && !/awake|in ?bed/i.test(r.stage)) d(localDay(r.end_ts)).sleep_min += (r.end_ts - r.ts) / 60000; // Core, Deep, REM, Asleep count; filed on the day you wake
}
for (const v of Object.values(days)) {
v.heart_rate = v.hr.length ? { avg: Math.round(v.hr.reduce((a, b) => a + b, 0) / v.hr.length), min: Math.min(...v.hr), max: Math.max(...v.hr) } : null;
v.sleep_min = Math.round(v.sleep_min); delete v.hr;
}
return json({ ok: true, days });
}
return json({ ok: false, error: "not found" }, 404);
},
};
5. Deploy
npx wrangler d1 execute health --remote --file schema.sql
npx wrangler secret put HEALTH_TOKEN # type a long random string, e.g. from a password manager
npx wrangler deploy # prints https://health-receiver.<you>.workers.dev
6. Test it from your computer before touching the phone
curl -X POST "https://health-receiver.<you>.workers.dev/health" \
-H "x-health-token: YOUR-TOKEN" -H "content-type: application/json" \
--data '{"steps":"2026-10-08T07:12:00+05:30 | 52"}'
# {"ok":true,"received":1,"skipped":[]}
curl "https://health-receiver.<you>.workers.dev/days" -H "x-health-token: YOUR-TOKEN"
# {"ok":true,"days":{"2026-10-08":{"steps":52,"sleep_min":0,"heart_rate":null}}}
If both work, the server is done. Everything from here is on the iPhone.
Part 2: the Shortcut
Here's the format we're sending: plain timestamp | value lines inside JSON. It's trivial to build in Shortcuts and trivial to parse anywhere.
{
"steps": "2026-10-08T07:12:00+05:30 | 52\n2026-10-08T07:24:31+05:30 | 118",
"heart rate": "2026-10-08T07:12:00+05:30 | 64",
"sleep": "2026-10-08T23:40:00+05:30 | 2026-10-09T01:10:00+05:30 | Core"
}
Open Shortcuts → + and build this. Action names below are what iOS shows; exact labels move around a little between iOS versions.
For each metric (Steps, Heart Rate, Sleep Analysis):
-
Find Health Samples
- Type:
Steps(thenHeart Rate, thenSleep Analysis) - Start Date is in the last 2 days (not 1; see trap #2)
- Sort by: Start Date. Limit: off
- Type:
-
Repeat with Each item in the samples. Inside it, a Text action:
- Steps and Heart Rate:
Start Date|Value - Sleep:
Start Date|End Date|Value(for sleep, the Value is the stage:Core,Deep,REM,AsleeporAwake) - Tap each date variable and set its format to ISO 8601 so the time zone is included.
- Steps and Heart Rate:
-
Combine Text:
Repeat Resultswith New Lines.
Then once, at the end:
-
Get Contents of URL
- URL: your Worker URL +
/health - Tap Show More. Method:
POST - Headers → Add new header:
X-Health-Token= your token - Request Body:
JSON→ add three Text fields:steps,heart rate,sleep, each set to its Combined Text variable
- URL: your Worker URL +
Run it once by hand. Then hit /days again; your real numbers should be there.
Part 3: make it automatic
Shortcuts → Automation → + → Personal Automation, then set it to Run Immediately (so it doesn't ask for a tap) and run your Shortcut.
Which trigger? This is where most guides would say "Time of Day, 7 AM". Read trap #1 first.
The four traps that fail silently
None of these throws an error you'll see. You just get less data than you think.
Trap 1: "It runs, but nothing arrives"
I set the automation for 07:00 and 23:55. The Shortcut fired every time. In three days, only two sends reached the server, and neither came at a scheduled time.
The reason: Health data is encrypted while your iPhone is locked. At 07:00 and 23:55 my phone was almost always locked, so the run could start but couldn't read Health, and nothing was sent.
Fix: trigger on something that only happens when the phone is unlocked. App → Is Opened → (an app you open every morning and evening). You open WhatsApp at 07:08, the sync runs, done. Keep the time-of-day automation as a bonus if you like; it'll work whenever the phone happens to be unlocked.
Make failures visible: have your server log every request, including rejected ones, with a timestamp and status. If the log is empty at 07:00, the phone never sent anything. If it shows a 401, your token header is wrong. Those are different fixes, and without a log they look identical.
Trap 2: a missed run is a hole forever
If each run sends "the last 1 day" and one run fails, those hours are simply gone. The next run starts after them.
Fix: send the last 2 days every time, and make the server ignore samples it already has. That's the UNIQUE(metric, ts, value, stage) + INSERT OR IGNORE from Part 1. Re-sending yesterday costs nothing, and one good run fills the gap a failed one left.
Trap 3: the last sleep stage has no end
Sleep isn't one number in Health. It's a list of stages, each with its own start and end.
If your Text action only sends Start Date | Value, you can only guess each stage's length from when the next one starts, and the last stage has nothing after it. It gets dropped, and your total comes out short every night. (The receiver above doesn't guess: it skips lines without an end time and lists them in skipped, so you'll notice.)
Fix: send Start Date | End Date | Value. Then count every stage except Awake and In Bed, and file the night under the day you wake up, not the day you went to bed. Otherwise a 23:40 → 06:45 night splits across two dates.
Trap 4: your steps are double counted
If you have an iPhone and an Apple Watch, both write step samples for the same walk. The Health app merges them. Summing the raw samples doesn't.
Fix: in Find Health Samples, add a filter for one Source (your Watch). Then check one day: does /days match the Steps number in the Health app? If yes, you're done.
What you get back
curl "https://health-receiver.<you>.workers.dev/days" -H "x-health-token: YOUR-TOKEN"
{
"ok": true,
"days": {
"2026-10-09": { "steps": 8120, "sleep_min": 452, "heart_rate": { "avg": 76, "min": 52, "max": 128 } }
}
}
From here it's just JSON. Chart it. Alert yourself when sleep drops under 6 hours three nights running. Or give it to an AI assistant as a tool: "how did I sleep this week?" is a lot more useful when the answer is your actual numbers, not a guess.
Quick reference
| Problem | Symptom | Fix |
|---|---|---|
| Phone locked at run time | Automation "runs", server log empty | Trigger on App is opened |
| Missed run | Gap that never fills | Send last 2 days + INSERT OR IGNORE
|
| Last sleep stage missing | Sleep a bit short every night | Send the end time with each stage |
| Night split across dates | Two half-nights | File sleep under the wake date |
| iPhone + Watch | Steps too high | Filter Source = Watch, compare with Health app |
| Wrong token / URL | 401 or nothing | Log every request with its status |
The surprising part for me wasn't the code. It was how much of "automation" is really making failure visible. The Shortcut said it ran. The server said nothing arrived. Both were telling the truth.
What would you build on top of your own Health data, now that it's plain JSON? A sleep alert, a weekly report, an AI coach? Or have you found a better trigger than "app opened"? Tell me in the comments.
Follow me here. I write about practical automation and building small, useful tools with AI.










Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.