DEV Community

Cover image for Apple won't give you an API for your Health data. A free Shortcut will (here's the whole pipeline)
Rudratosh Shastri
Rudratosh Shastri

Posted on

Apple won't give you an API for your Health data. A free Shortcut will (here's the whole pipeline)

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.

The whole pipeline: Watch → Health → Shortcut → Worker → your data

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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);
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}}}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Open Shortcuts → + and build this. Action names below are what iOS shows; exact labels move around a little between iOS versions.

The finished Shortcut: Find Health Samples, Repeat with Each, Text, Combine Text

For each metric (Steps, Heart Rate, Sleep Analysis):

  1. Find Health Samples
    • Type: Steps (then Heart Rate, then Sleep Analysis)
    • Start Date is in the last 2 days (not 1; see trap #2)
    • Sort by: Start Date. Limit: off
  2. 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, Asleep or Awake)
    • Tap each date variable and set its format to ISO 8601 so the time zone is included.
  3. Combine Text: Repeat Results with New Lines.

Find Health Samples set to Steps, in the last 2 days

Inside Repeat with Each: a Text action with Start Date (ISO 8601) | Value

Then once, at the end:

  1. 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

Get Contents of URL with POST, the token header and three JSON fields

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.

New Automation: When WhatsApp is Opened, Run Immediately


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.

Time-of-day runs on a locked phone fail; app-opened runs succeed

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.

With a 2-day window, the next run backfills a missed one

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.

One night as Health stores it: each stage has a start and an 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.

iPhone + Watch both record the same walk; a naive sum double counts

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"
Enter fullscreen mode Exit fullscreen mode
{
  "ok": true,
  "days": {
    "2026-10-09": { "steps": 8120, "sleep_min": 452, "heart_rate": { "avg": 76, "min": 52, "max": 128 } }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.