Developers

API reference

A small, predictable REST API over your Apple Health data. JSON in, JSON out, Bearer auth.

Quickstart

Install the HealthAPI app for iPhone, authorize Apple Health, and open Connect, then Other tools (or Settings, Sync Settings), to copy your personal key. Then call the API with your key as a Bearer token. The base URL is:

URL
https://healthapi.app/api/v1

Every example below is a complete request: drop in your key and run it.

Authentication

All endpoints require your API key in the Authorization header. There are no other credentials: the key is the identity.

HTTP
Authorization: Bearer <YOUR_API_KEY>

Keys are issued by the iPhone app and can be rotated from the Console or via /account/regenerate-key. A missing or invalid key returns 401.

AI agents & automations

Claude, ChatGPT and other MCP clients connect without a key: Claude from its connector directory, the others with the address below, and you approve it in the HealthAPI app on your iPhone. Step-by-step guides: Claude, ChatGPT, and both on one page.

URL
https://healthapi.app/mcp
ClientHow
ClaudeFind HealthAPI in Claude's connector directory (Customize, Connectors, +) and select Connect, or add it by its address as a custom connector. Every plan, including Free. Set it up on the web or desktop and it works in the mobile apps too.
ChatGPTPlugins, +, add a custom MCP server with the address and OAuth, Create as a plugin, then install it and type @ in a chat. Plus, Pro, Business, Enterprise or Edu, on chatgpt.com in a browser on a computer; not Free or Go.
Mistral VibeConnectors, Add connector, Add a custom connector, with the address as Server; OAuth is detected. Every plan, including Free, at chat.mistral.ai.
Claude Codeclaude mcp add --transport http healthapi https://healthapi.app/mcp, then /mcp to sign in.
Cursor, VS CodeAn HTTP MCP server with url set to the address (~/.cursor/mcp.json, .vscode/mcp.json).

Sign-in is OAuth 2.1 with PKCE, discovered from /.well-known/oauth-protected-resource/mcp. Every tool is read only, and each call counts as one request toward your daily quota:

ToolReturns
get_health_overviewWhat is synced: every metric with its first and last day, workouts, last sync, timezone, requests left. Start here.
get_daily_summaryOne day: activity, heart, vitals, workouts and the night's sleep.
get_weekly_summaryA Monday-to-Sunday week: totals, daily averages, a line per day.
get_sleepPer night: asleep (core, deep, REM), awake, in bed, bedtime and wake time.
get_trendOne metric day by day, with average, low and high.
get_latestThe newest reading of each metric.
get_workoutsWorkouts between two days, with totals.
get_samplesRaw readings of one metric, up to 31 days.

Each connection shows in the app under Settings, Connected apps, where you can disconnect it. Regenerating your key disconnects them all.

Anything that can call a REST API works with your key: custom GPTs, n8n, Zapier, Make, Claude Code, Cursor, your own scripts. The whole API is described in an OpenAPI 3.1 spec, and llms.txt sums it up for AI agents.

ToolSetup
Custom GPTConfigure, Actions, Import from URL: https://healthapi.app/openapi.json. Authentication: API Key, Bearer, your key. Privacy policy: https://healthapi.app/privacy.
n8n, Make, ZapierAn HTTP request to an endpoint below with the header Authorization: Bearer <key>, or import the OpenAPI spec where supported.
Agents and scriptsPoint the agent at https://healthapi.app/llms.txt and give it your key. Call GET /usage first.

Your key can read everything you've synced, so give it only to tools you trust, and regenerate it in the app to cut them off.

Usage & quota

GET /usage

Returns your plan and how much of your daily quota you've used. This call is cheap, reflects live consumption and doesn't count toward the quota. access_status is trialing, subscribed or expired; when it's expired, health reads return 402.

RequestShell
curl "https://healthapi.app/api/v1/usage" \
  -H "Authorization: Bearer $KEY"
ResponseJSON
{
  "plan_tier": "pro",
  "plan_label": "Pro",
  "window": "1d",
  "limit": 100,
  "used": 7,
  "remaining": 93,
  "reset_at": "2026-10-13T00:00:00.000Z",
  "apple_linked": true,
  "access_status": "subscribed",
  "trial_ends_at": null,
  "deletion_scheduled_at": null,
  "locked": false
}

Daily summary

GET /query/daily-summary

Steps, average heart rate, and workouts for a single local day.

ParamTypeNotes
datestringYYYY-MM-DD. Defaults to today in your timezone.
tzstringIANA zone, e.g. America/New_York. Defaults to your account timezone.
RequestShell
curl "https://healthapi.app/api/v1/query/daily-summary?date=2026-10-12&tz=America/New_York" \
  -H "Authorization: Bearer $KEY"
ResponseJSON
{
  "date": "2026-10-12",
  "timezone": "America/New_York",
  "range_local": {
    "start": "2026-10-12T00:00:00.000-04:00",
    "end": "2026-10-13T00:00:00.000-04:00"
  },
  "range_utc": {
    "start": "2026-10-12T04:00:00.000Z",
    "end": "2026-10-13T04:00:00.000Z"
  },
  "steps": 11284,
  "avg_heart_rate": 62.4,
  "workouts": [
    {
      "type": "running",
      "duration_minutes": 41.5,
      "calories": 386.4,
      "start_date_utc": "2026-10-12T11:02:00.000Z",
      "end_date_utc": "2026-10-12T11:43:30.000Z",
      "start_date_local": "2026-10-12T07:02:00.000-04:00",
      "end_date_local": "2026-10-12T07:43:30.000-04:00"
    }
  ]
}

Step totals are aggregated across sources, so overlapping iPhone and Apple Watch samples aren't counted twice, and the number matches Apple Health.

GET /query/trends

Per-day statistics for one metric over a window.

ParamTypeNotes
typestringRequired. e.g. stepCount, heartRate, activeEnergyBurned.
daysinteger1–365. Defaults to 7.
tzstringIANA zone. Optional.
RequestShell
curl "https://healthapi.app/api/v1/query/trends?type=heartRate&days=7&tz=UTC" \
  -H "Authorization: Bearer $KEY"
ResponseJSON
{
  "type": "heartRate",
  "timezone": "UTC",
  "period_days": 7,
  "aggregation": "avg",
  "start_date": "2026-10-06",
  "end_date": "2026-10-12",
  "data": [
    { "date": "2026-10-11", "avg": 64, "min": 48, "max": 142, "total": null, "samples": null },
    { "date": "2026-10-12", "avg": 62, "min": 51, "max": 156, "total": null, "samples": null }
  ]
}

For point-in-time metrics (heart rate, HRV, SpO₂), each day's avg/min/max is served from a pre-computed daily rollup, so long ranges stay fast. Summed metrics (steps, energy, distance) fill total instead. samples can be null on rolled-up days, and days with no data are left out of data.

Full daily aggregate

GET /health/daily-summary

The complete daily roll-up: steps, active & basal energy, distance, flights, exercise time, heart-rate averages, sleep, and workouts.

ParamTypeNotes
datestringRequired. YYYY-MM-DD.
tzstringIANA zone. Optional.
RequestShell
curl "https://healthapi.app/api/v1/health/daily-summary?date=2026-10-12" \
  -H "Authorization: Bearer $KEY"
ResponseJSON
{
  "totals": {
    "steps": 11284,
    "distance_m": 8600,
    "active_energy_kcal": 684,
    "basal_energy_kcal": 1633,
    "flights_climbed": 9,
    "exercise_time_min": 52
  },
  "averages": {
    "heart_rate_bpm": 62,
    "resting_heart_rate_bpm": 54,
    "hrv_ms": 68,
    "spo2_pct": 98,
    "respiratory_rate_bpm": 14
  },
  "sleep": { "total_hours": 7.7, "session_count": 13 },
  "workouts": { "count": 1, "total_duration_seconds": 2490, "total_calories_kcal": 386.4 },
  "data_coverage_pct": 100,

  "sleep_hours": 7.7,
  "sleep_minutes": 462,
  "sleep_session_count": 13,
  "workout_count": 1,
  "workout_duration_seconds": 2490,
  "workout_duration_minutes": 41.5,
  "workout_calories_kcal": 386.4,
  "workout_calories": 386.4
}

The flat fields after data_coverage_pct are aliases: they carry the same values as the nested sleep and workouts objects, in both hour/second and minute units, so you can map whichever naming your pipeline expects. Mind the units: this endpoint reports sleep in hours and workout duration in seconds, while /query/daily-summary reports each workout in minutes. Reading a minute-named field off an hour-named response is the most common cause of a summary that shows zero or null on real data.

FieldUnitNotes
sleep_hours / sleep_minuteshours / minutesTime asleep in the night that ended that morning: sleep that ended from 6 pm the day before up to 6 pm that day. Same value as sleep.total_hours.
sleep_session_countcountSleep samples recorded, not nights. Apple Watch writes several stage segments per night.
workout_countcountWorkouts starting within the local day.
workout_duration_seconds / workout_duration_minutesseconds / minutesTotal across all workouts that day.
workout_calories_kcal / workout_calorieskcalActive energy across all workouts that day.
data_coverage_pctpercentHow many of 7 expected daily sources reported: steps, active energy, basal energy, heart rate, distance, exercise time, and sleep. 85 means one is missing.

Weekly summary

GET /health/weekly-summary

The same aggregate as the full daily roll-up, totalled and averaged across an ISO calendar week.

ParamTypeNotes
weekstringRequired. ISO week in YYYY-Www format, e.g. 2026-W41.
tzstringIANA zone. Optional.
RequestShell
curl "https://healthapi.app/api/v1/health/weekly-summary?week=2026-W41" \
  -H "Authorization: Bearer $KEY"

Raw samples

GET /health/samples

Individual data points, for when you want the raw series, not an aggregate. All filters are optional; results are newest-first. Each row has a kind: reading (as Apple Health recorded it; iPhone and Watch readings can overlap), total (the app's deduplicated sum for that period) or daily_average (one day's average). Only total rows are safe to add up. For daily figures, trends and the daily summary are simpler.

Each reading also says where it came from: source_app is the app or device that wrote it to Apple Health (for example Oura, Eight Sleep or Alex's Apple Watch), and device is the hardware Apple Health recorded, when the writing app provides one (Apple Watch readings say Apple Watch; most other apps leave it null). Both are null on total and daily_average rows, which combine every source.

ParamTypeNotes
typestringOptional. Filter by metric (e.g. heartRate).
kindstringOptional. reading, total or daily_average.
sourcestringOptional. Only readings whose source_app contains this text, ignoring case (e.g. Oura or Watch).
start_datestringLocal day, YYYY-MM-DD. Optional.
end_datestringLocal day, YYYY-MM-DD. Optional.
limitinteger1–20000. Defaults to 100.
offsetintegerRows to skip, for paging. Defaults to 0.
RequestShell
curl "https://healthapi.app/api/v1/health/samples?type=heartRate&start_date=2026-10-11&end_date=2026-10-12" \
  -H "Authorization: Bearer $KEY"

Workouts

GET /health/workouts

Your workout sessions: type, duration, distance, and energy.

ParamTypeNotes
activity_typestringOptional. Filter by workout activity type.
start_datestringLocal day, YYYY-MM-DD. Optional.
end_datestringLocal day, YYYY-MM-DD. Optional.
limitinteger1–20000. Defaults to 100.
offsetintegerRows to skip, for paging. Defaults to 0.
RequestShell
curl "https://healthapi.app/api/v1/health/workouts?limit=20" \
  -H "Authorization: Bearer $KEY"

Latest readings

GET /health/latest

The most recent reading for every metric you sync, in one indexed call, ideal for a dashboard.

RequestShell
curl "https://healthapi.app/api/v1/health/latest" \
  -H "Authorization: Bearer $KEY"
ResponseJSON
{
  "heartRate": { "value": 62, "unit": "count/min", "date": "2026-10-12T14:02:00Z" },
  …
}

Regenerate key

POST /account/regenerate-key

Issues a new key and invalidates the current one immediately. It also disconnects every app connected through the connector, such as Claude and ChatGPT. Update your scripts and integrations that use the key afterward.

RequestShell
curl -X POST "https://healthapi.app/api/v1/account/regenerate-key" \
  -H "Authorization: Bearer $KEY"
ResponseJSON
{
  "api_key": "<new key>",
  "created_at": "2026-10-12T14:22:00Z",
  "expires_at": null
}

Export your data

GET /gdpr/export

Downloads a complete JSON export of your account, health samples, and workouts.

RequestShell
curl "https://healthapi.app/api/v1/gdpr/export" \
  -H "Authorization: Bearer $KEY" \
  -o export.json

Delete account

DELETE /account/delete

Deactivates your account immediately and schedules permanent deletion. You have a 30-day grace period: sign back in within 30 days to recover everything. For immediate, irreversible erasure, use DELETE /gdpr/account with a confirmation body.

RequestShell
curl -X DELETE "https://healthapi.app/api/v1/account/delete" \
  -H "Authorization: Bearer $KEY"

Rate limits

Quotas are per account, per day, and the window resets at 00:00 UTC. Your own data uploads from the app don't count against them; only API queries do.

Free for 2 days, no card needed. Then an optional 7-day free trial when you subscribe. When the 2 days are up, AI apps and the API stop reading your data until you choose a plan in the app. Your synced data is kept for about 5 weeks, so subscribing picks up where you left off. There is no permanent free tier. The plans differ only in requests per day and are bought and managed in the iPhone app; see plans for details.

PlanRequests / dayPriceBest for
Free preview25Free for 2 daysBefore you subscribe, no card needed
Pro100$4.99 a month or $49.99 a yearPersonal dashboards
Developer1,000$9.99 a month or $99.99 a yearAgents that poll

Only health reads (/health/* and /query/*) and connector tool calls count toward the daily quota, including ones rejected for bad input (400 or 422). Health read responses carry your live quota in headers, with Reset as a Unix timestamp:

Response headersHTTP
X-RateLimit-Limit: 25
X-RateLimit-Remaining: 18
X-RateLimit-Reset: 1791849600

Exceeding the limit returns 429 Too Many Requests with a Retry-After header. Separately, most REST routes allow 100 requests a minute per IP address; account routes (/usage, /gdpr, /account) report that limit in the same headers, with Reset in seconds.

Errors

Errors return the appropriate HTTP status with a JSON body shaped like:

Error bodyJSON
{
  "detail": "Rate limit exceeded. Your Pro plan allows 100 requests/day. A plan with a higher daily limit is available in the HealthAPI app."
}
StatusMeaning
400A malformed date or week, a date in the future, or an unknown tz.
401Missing or invalid API key.
402No active subscription. Your free preview has ended, or your subscription has lapsed (code = subscription_required, next to detail). Your synced data is kept for about 5 weeks, and syncing and reads resume as soon as you subscribe. This is the normal end state of a preview, not an error in your integration.
409Conflict, for example an Apple ID already linked to another account (detail.code = account_exists).
422Validation error: a required parameter is missing or malformed.
429Rate limit exceeded. Back off until Retry-After.