Before you start
Install HealthAPI on your iPhone and let it sync. Copy your key from the app (Connect, then Other tools) and keep it in an environment variable, not in your code:
export HEALTHAPI_KEY="your key"A small client
One function for any endpoint. It sends the key as a Bearer token, turns error responses into an error you can check, and needs no packages:
// healthapi.mjs: Node 18 or later, Bun and Deno all have fetch built in.
const API = "https://healthapi.app/api/v1";
export class HealthApiError extends Error {
constructor(status, body, retryAfter) {
super(typeof body.detail === "string" ? body.detail : `HealthAPI returned ${status}`);
this.status = status;
this.body = body;
this.retryAfter = retryAfter; // seconds, on a 429
}
}
export function healthapi(key) {
if (!key) throw new Error("Set HEALTHAPI_KEY to your HealthAPI key.");
return async function get(path, params = {}) {
const url = new URL(API + path);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, String(value));
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new HealthApiError(res.status, body, Number(res.headers.get("Retry-After")) || null);
}
return body;
};
}A week in a table
The trends endpoint gives one metric a day. Its aggregation field says where the day's value is: total for steps and energy, avg for heart rate, HRV and the like.
// week.mjs
import { healthapi } from "./healthapi.mjs";
const get = healthapi(process.env.HEALTHAPI_KEY);
const trends = await Promise.all([
get("/query/trends", { type: "stepCount", days: 7 }),
get("/query/trends", { type: "restingHeartRate", days: 7 }),
]);
// Summed metrics carry the day's value in total, the others in avg.
const days = {};
for (const t of trends) {
const field = t.aggregation === "sum" ? "total" : "avg";
for (const day of t.data) days[day.date] = { ...days[day.date], [t.type]: day[field] };
}
console.table(days);The same file runs in all three. Deno needs permission for the network and the one variable:
node week.mjs
bun week.mjs
deno run --allow-net=healthapi.app --allow-env=HEALTHAPI_KEY week.mjs┌────────────┬───────────┬──────────────────┐
│ (index) │ stepCount │ restingHeartRate │
├────────────┼───────────┼──────────────────┤
│ 2026-10-16 │ 7333 │ 56 │
│ 2026-10-17 │ 10871 │ 54 │
│ 2026-10-18 │ 11284 │ 54 │
│ 2026-10-19 │ 9120 │ 55 │
│ 2026-10-20 │ 12408 │ 54 │
│ 2026-10-21 │ 8016 │ 55 │
│ 2026-10-22 │ 2310 │ 53 │
└────────────┴───────────┴──────────────────┘Today is a partial day, and days with no data are left out of data. Daily and weekly summaries, sleep, workouts and raw readings are in the API reference.
TypeScript types from the OpenAPI spec
The whole API is described in an OpenAPI 3.1 file. Generate types from it, and openapi-fetch checks paths, parameters and responses as you type, metric names included:
npm install openapi-fetch
npx openapi-typescript https://healthapi.app/openapi.json -o api.d.ts// hrv.mts
import createClient from "openapi-fetch";
import type { paths } from "./api.d.ts";
const api = createClient<paths>({
baseUrl: "https://healthapi.app/api/v1",
headers: { Authorization: `Bearer ${process.env.HEALTHAPI_KEY}` },
});
const { data, error, response } = await api.GET("/query/trends", {
params: { query: { type: "heartRateVariability", days: 30 } },
});
if (error) throw new Error(`HealthAPI ${response.status}: ${JSON.stringify(error.detail)}`);
for (const day of data.data) console.log(day.date, day.avg);Bun runs it as is, and so does Node 24 (node hrv.mts). The .mts ending makes it a module wherever it lives, and the generated file is only types, so it adds nothing at run time.
Errors to handle
| Status | Meaning | What to do |
|---|---|---|
401 | The key is missing or wrong. | Copy it again from the app. A regenerated key replaces the old one. |
402 | No active plan: the free preview ended or the subscription lapsed. The body has code subscription_required. | Choose a plan in the app. Synced data is kept for about 5 weeks; don't retry in a loop. |
429 | The day's requests are used up. The body has limit and reset, with a Retry-After header. | Wait until the reset at 00:00 UTC. |
400, 422 | A bad date, timezone, metric or missing parameter. | Read detail, fix the request. |
import { healthapi, HealthApiError } from "./healthapi.mjs";
const get = healthapi(process.env.HEALTHAPI_KEY);
try {
const today = await get("/query/daily-summary");
console.log(`${today.steps} steps so far today`);
} catch (e) {
if (!(e instanceof HealthApiError)) throw e;
if (e.status === 401) console.error("The key is wrong or missing. Copy it again from the app.");
else if (e.status === 402) console.error("This account has no active plan:", e.body.code);
else if (e.status === 429) console.error(`${e.message} Retry in ${e.retryAfter ?? 60} seconds.`);
else console.error(e.status, e.message);
}A 429 can also mean too many requests a minute from one address, or too many wrong keys; then the body has only detail.
Daily limits
| Plan | Requests a day |
|---|---|
| Free preview (2 days) | 25 |
| Pro | 100 |
| Developer | 1,000 |
Each health read is one request, and the count resets at 00:00 UTC. Health reads also return X-RateLimit-Remaining. To check without spending one:
const usage = await get("/usage"); // free: doesn't count toward the limit
console.log(`${usage.remaining} of ${usage.limit} left, resets ${usage.reset_at}`);Not in the browser
Call the API from server-side code only. A key in browser JavaScript can be read by anyone who opens the page, and it unlocks everything you've synced. The API won't answer other websites' pages anyway: it only allows HealthAPI's own site, so the browser blocks the response. Fetch on a server, in a serverless function or at build time, and send the page only the numbers it shows. If a key ever leaks, regenerate it in the app.
For a spreadsheet or an automation instead of code, see the n8n guide; for pandas, the Python guide; to have an AI agent write this code with your real data in view, the Claude Code guide.
Questions
Is there an Apple Health API for JavaScript?
Not from Apple. HealthKit is a framework for apps running on the iPhone and Apple Watch, and Apple offers no web API for Health data. A server, a script or a website has to read it from a service your iPhone syncs to, such as HealthAPI.
Can I use it in Next.js?
Yes, from code that runs on the server: a route handler, a server component or a server action, with the key in an environment variable. Never from a client component, where the key would ship to the browser.
How many requests does the code use?
One per call: each trend covers up to 365 days in one request, so the week table is two. Requests rejected for bad input (400 or 422) count too. GET /usage doesn't count.
Can it tell me when new data arrives?
No, there are no webhooks. Data is as current as your iPhone's last sync, so read it on a schedule, for example once each morning for yesterday.
Which metric names can I use?
HealthKit's own, without the prefix: stepCount, restingHeartRate, heartRateVariability and so on. The metrics page lists all 50, and the generated types autocomplete them.
