How do I get Apple Health data into n8n?

Install Life Dashboard Companion on your iPhone and enter the production URL of an n8n Webhook node as its webhook. In n8n, turn on the Webhook node's Raw Body option, check the X-Signature HMAC in a Code node, answer 204 with a Respond to Webhook node, and then store each record keyed on its type and uuid. Everything in Apple Health reaches n8n this way, from the iPhone, an Apple Watch or another app that writes there.

Get the iPhone app

The iPhone app sends the Android app's payload, so the workflow is the one in Health Connect to n8n, and one workflow takes both phones. This page has the whole workflow again, with the places where an iPhone payload differs. The payload itself, retries and the queue are in Apple Health to a webhook.

iPhone or Apple WatchApple HealthLife Dashboard Companionn8n

What you need

  • Life Dashboard Companion for iPhone on iOS 17 or newer. It's on the App Store for €6.99 once (US$5.99), no subscription. Building it from source with Xcode on a Mac stays free.
  • n8n, on n8n Cloud or self-hosted, reachable from the iPhone. Use HTTPS. iOS allows plain http:// only to an IP address, a .local name or a name without a dot, and asks once for access to the local network: allow it for an n8n at home.
  • Room for large requests. n8n takes up to 16 MB by default, but a reverse proxy in front of it must allow that too: nginx allows 1 MB by default.
  • The crypto module in the Code node. n8n Cloud provides it. On self-hosted n8n, set NODE_FUNCTION_ALLOW_BUILTIN=crypto, on the task runner if you run one.

The workflow at a glance

Webhook (POST, Raw Body)
  -> Code: check signature
  -> If: valid is true
       true  -> Respond to Webhook (204)
                  -> Code: records    -> Postgres: upsert
                  -> Code: deletions  -> Postgres: delete
       false -> Respond to Webhook (401)

The response goes out before the database work: the app gives up on an attempt once the connection stands still for 10 seconds.

Set up the Webhook node

  1. Add a Webhook node. Set HTTP Method to POST and pick a Path, such as life-dashboard.
  2. Set Respond to Using 'Respond to Webhook' Node. With Immediately, n8n answers 200 before the signature check runs, so a payload with a bad signature still counts as delivered.
  3. Under Options, add Raw Body and switch it on. The node then outputs the bytes as they arrived in the binary property data, next to the parsed JSON in body and the headers in headers.
  4. Publish the workflow and copy the production URL. The test URL only listens while the editor waits for a test event. A sync sent while nothing listens gets a 404, and the iPhone keeps that payload and the ones after it in its queue until the URL answers again.

In the app, add the production URL under Webhook on the Health tab, set an HMAC Signing Secret under the webhook's custom headers, and copy it for the next step.

Webhook and MQTT settings in the iPhone app

Check the signature in a Code node

The iPhone signs exactly as the Android app does: X-Signature: sha256=<hex>, the HMAC-SHA256 of the raw body with your secret as the key. Add a Code node in JavaScript, mode Run Once for All Items:

const crypto = require('crypto');

// The secret as the app shows it. Use it as text: don't hex-decode it.
const SECRET = 'paste-the-secret-from-the-app';

const item = $input.first();
const raw = await this.helpers.getBinaryDataBuffer(0, 'data');
const expected = Buffer.from(
  'sha256=' + crypto.createHmac('sha256', SECRET).update(raw).digest('hex')
);
const presented = Buffer.from(item.json.headers['x-signature'] || '');
const valid = presented.length === expected.length &&
  crypto.timingSafeEqual(presented, expected);

return [{ json: { valid, payload: valid ? JSON.parse(raw.toString('utf8')) : null } }];
  • The HMAC runs over raw, the bytes the app signed. Computing it over body serialized again gives a different string, and the check fails.
  • n8n gives header names in lowercase, so the header is x-signature.
  • Anyone who can open the workflow can read the secret in the code. The payload reference shows the same check with a Crypto node, which keeps the secret in an n8n credential.

Then add an If node with one condition: the boolean {{ $json.valid }} is true.

Respond before the slow part

End each branch of the If node in its own Respond to Webhook node with Respond With set to No Data. Under Options, set Response Code to 204 on the true branch and 401 on the false branch. What the iPhone does with each answer:

  • 204. Any 2xx counts as delivered. The nodes after the Respond to Webhook node still run, with the same item.
  • 401. Not retried within the sync. The payload stays in the iPhone's queue, the ones after it wait behind it, and they go out once the secrets match: at the next sync, or with Retry Now.
  • 500. If the workflow fails before a Respond to Webhook node runs, n8n answers 500. The iPhone tries 3 times in all, 1 and then 2 seconds apart, then keeps the payload for the next sync.

Two things differ from Android: a payload your workflow refuses with 400, 413 or 422 is offered again once a day rather than on every sync, and a payload older than 7 days is dropped at the next delivery n8n answers with an error.

Turn the payload into rows

After the 204, a second Code node turns the payload into one item per record. It's the same code as for Android:

const p = $input.first().json.payload;
if (!p || p.test) return [];

const rows = [];
for (const [type, value] of Object.entries(p)) {
  if (type === 'deleted_records' || !Array.isArray(value)) continue;
  for (const r of value) {
    if (!r || !r.uuid) continue; // daily totals and buckets carry no uuid
    rows.push({ json: {
      type,
      uuid: r.uuid,
      source: r.source ?? null,
      time: r.time ?? r.start_time ?? r.session_end_time ?? null,
      data: r,
    } });
  }
}
return rows;
  • A Test ping carries "test": true and no records, so nothing after this node runs.
  • On an iPhone, a record's source is the name Apple Health gives the app or device that wrote it, such as Alex's Apple Watch, not a package name. It often includes the owner's name.
  • A sleep night's uuid comes from its earliest stage, so it stays the same while the night grows, and the upsert below replaces the night when it comes back longer. A night's stages carry their own uuid and source in data.
  • A food is one nutrition record with every nutrient in it, and its name when the app gave one.
  • Series you set to 1, 5 or 15 minutes or hourly under Data Resolution arrive as buckets without a uuid and are skipped here. Leave every type on every record for this workflow, which is the default.

Store it in Postgres, deduplicated on uuid

The same record can arrive more than once: in every backfill, and again from the queue after a delivery that iOS cut off. Key the table on type and uuid, both as text:

CREATE TABLE health_records (
  type   text NOT NULL,
  uuid   text NOT NULL,
  source text,
  time   timestamptz,
  data   jsonb NOT NULL,
  PRIMARY KEY (type, uuid)
);

Add a Postgres node with the operation Execute Query:

INSERT INTO health_records (type, uuid, source, time, data)
VALUES ($1, $2, $3, $4::timestamptz, $5::jsonb)
ON CONFLICT (type, uuid) DO UPDATE
SET source = EXCLUDED.source, time = EXCLUDED.time, data = EXCLUDED.data;

Under Options, add Query Parameters with this expression:

{{ [ $json.type, $json.uuid, $json.source, $json.time, JSON.stringify($json.data) ] }}
  • Pass values as Query Parameters, never as {{ }} inside the SQL. n8n pastes an expression in the SQL as plain text, so a quote in a value, such as the one in Alex's Apple Watch, breaks the query.
  • The key is type plus uuid, not uuid alone: an iPhone sends one active energy sample under both active_calories and total_calories.
  • Apple Health doesn't edit a record in place. An edit arrives as a deletion of the old uuid and a new record, which the next step handles.

Apply deleted_records

A record deleted in Apple Health is named in a later payload's deleted_records, with its type and uuid. A third Code node, also after the 204:

const p = $input.first().json.payload;
return (p?.deleted_records ?? []).map(d => ({ json: { type: d.type, uuid: d.uuid } }));

Then a Postgres node with Execute Query and the Query Parameters {{ [ $json.type, $json.uuid ] }}:

DELETE FROM health_records
WHERE type = $1 AND (uuid = $2 OR starts_with(uuid, $2 || '#'));

An iPhone's uuids never carry a #, so for an iPhone only the first half matters; the second half is for heart rate and skin temperature samples from Android, and lets one workflow serve both. A deleted uuid you don't have deletes nothing, which is what the iOS payload page asks for.

One iPhone case the query above doesn't cover: a sleep deletion names one stage of the night, by its uuid in stages, not the night. To drop that stage from the stored night, add a second Postgres node with the same Query Parameters:

UPDATE health_records
SET data = jsonb_set(data, '{stages}', (
  SELECT coalesce(jsonb_agg(s), '[]'::jsonb)
  FROM jsonb_array_elements(data->'stages') AS s
  WHERE s->>'uuid' IS DISTINCT FROM $2))
WHERE type = 'sleep' AND $1 = 'sleep'
  AND data->'stages' @> jsonb_build_array(jsonb_build_object('uuid', $2::text));

The night's duration_seconds stays as it was sent. A type listed in deletions_unavailable can't be vouched for on that sync: run a backfill from the app to reconcile it.

If you'd rather not build the storage yourself, the receiver of the Life Dashboard stack deduplicates and deletes the same way, with dashboards, a read-only API and an MCP server on top.

Google Sheets or Telegram instead

One row per day in Google Sheets. A Code node after the 204 takes the daily totals, which Apple Health counts the way the Health app does, so a walk recorded by the iPhone and the Watch counts once:

const p = $input.first().json.payload;
return (p?.daily_totals ?? []).map(d => ({ json: d }));

Then a Google Sheets node with Append or Update Row, a sheet with the columns date, steps, distance_meters, active_calories and total_calories, and date as the column to match on. Every payload carries today and the two days before, so today's row updates with every sync. On an iPhone, total_calories only comes on days with resting energy, which an iPhone without an Apple Watch usually doesn't record.

A Telegram message per new weight. After the records node, a Filter node keeps items where {{ $json.type }} is weight. A Remove Duplicates node with Remove Items Processed in Previous Executions, Keep Items Where set to Value Is New and {{ $json.uuid }} as the value to dedupe on stops a second message when the same weighing comes again in a backfill. Then a Telegram node with Send Message and the text Weight: {{ $json.data.kilograms }} kg.

An iPhone and an Android phone in one workflow

  • The top-level source is healthkit_ios from an iPhone and health_connect from Android.
  • Heart rate variability is SDNN from an iPhone and RMSSD from Android, under the same key. Store the top-level source with each record so the two stay apart.
  • A workout's type is a name such as running from an iPhone, and Health Connect's number as a string, such as "56", from Android.
  • Sleep stages from an iPhone include in_bed, the time in bed around the stages.
  • Screen time only comes from Android, as its own payload.

The records, upsert and deletion steps on this page come from the workflow in Health Connect to n8n. The Code nodes and SQL were checked here against an iPhone payload in Node.js and Postgres, not in a running n8n with an iPhone sending to it, so try it with Test ping and Sync Now before you rely on it.

Limits

  • n8n can't ask the iPhone for a date range. Start a backfill of 30, 90 or 365 days on the iPhone, under Backfill on the Health tab.
  • iOS decides when the app runs in the background, so syncs can come later than the schedule says. For an exact time, a Shortcuts automation runs Sync Health Data.
  • Because the 204 goes out first, two payloads can be stored at the same time. Payloads carry a sequence that only goes up; use it when order matters, such as for daily_totals.

Common questions

Is the workflow different from the Android one?

Only in the details on this page. The Webhook node, the signature check, the responses and the upsert are the same, so one workflow can take both phones. The extra step is the sleep stage deletion, which only an iPhone sends.

Does this work on n8n Cloud?

Yes. The production URL of a published workflow is public HTTPS, and n8n Cloud provides the crypto module to the Code node. Self-hosted n8n needs NODE_FUNCTION_ALLOW_BUILTIN=crypto for the same code.

Why does the signature check fail on every request?

Usually one of three things: Raw Body is off, so there's no binary data to hash; the HMAC runs over the parsed body instead of the raw bytes; or the secret in n8n differs from the one in the app. Copy the secret again and use it as plain text.

Does Apple Watch data reach n8n?

Yes. The watch writes into Apple Health on the iPhone, and the app sends it on once the watch has synced with the iPhone. Apple Watch in Home Assistant lists what a watch typically records.

Can I see a payload before I build the workflow?

Yes. View on the app's Health tab shows the last 7 days as a payload. In n8n, the test URL with Listen for Test Event shows a real request in the editor.