> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getpostern.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect Apple Health

> Your iPhone pushes health data to an address the Console creates for it. Postern never pulls, and keeps what your phone sends from the day you start.

<Info>
  **Before you start**

  * **An iPhone, and the Health Auto Export app** from the App Store. Its REST API
    automations may need the app's paid tier. That is the app's paywall, not Postern's.
  * **Remote access, set up first.** Postern only answers requests from the machine it
    runs on. Your phone is a different machine, so
    [open two ports to your own devices](/start/remote-access#your-devices) first.
    Tailscale's private tier is enough. Nothing has to be public.
  * **Postern is up, with the Console open at `http://localhost:8787`.** Otherwise
    [install Postern](/start/install) first. Allow about 10 minutes, plus your first
    export.
  * **Your password manager open.** The Console shows the device secret once.
</Info>

<Steps>
  <Step title="Create the ingest endpoint in the Console" titleSize="h2" id="create-endpoint">
    Open the Console at `http://localhost:8787`. Go to **Sources** → **Add a source** →
    **Apple Health**.

    <Frame caption="The Apple Health card, before you press the button.">
      <img src="https://mintcdn.com/postern/E8R3wfogdGSukifu/images/apple-health-endpoint.png?fit=max&auto=format&n=E8R3wfogdGSukifu&q=85&s=a50c3d439cf37298d72c30ff3d1ba8f0" alt="The Console's Apple Health connect card, headed YOUR INGEST ENDPOINT. Its text: Postern mints a private ingest endpoint and a one-time secret for this device. Nothing is created until you ask. Below the text sits one button, Create your ingest endpoint." width="576" height="204" data-path="images/apple-health-endpoint.png" />
    </Frame>

    <Warning>
      Postern cannot show you this secret again. It keeps a scrambled copy it can check
      against, never the secret itself. If you lose it, press **Rotate secret** on the
      connection. That makes a new secret and stops every device that still sends the old one.
      Open your password manager before you press the button.
    </Warning>

    Press **Create your ingest endpoint**. The card returns two values, then reads
    *waiting for the first push*:

    * **Ingest path** — `/api/ingest/<connection-id>`
    * **Device secret — shown once**

    Copy the secret into your password manager now.

    The ingest path is not a web address. Postern is never told your remote-access address,
    so you build the address yourself:

    1. On the machine Postern runs on, run `tailscale status`. Read its Tailscale name. It
       looks like `gateway.example-tailnet.ts.net`.
    2. Put `https://`, that name and `:8443` in front of the path, exactly as the Console
       printed it. Port 8443 is the port you opened in
       [Open two ports to your own devices](/start/remote-access#your-devices).

    ```text theme={"system"}
    https://gateway.example-tailnet.ts.net:8443/api/ingest/<connection-id>
    ```

    Copy that address. It goes into your phone in step 3.

    Any address you put in front of the path works. The secret is what protects it.
    [The ingest route is the one route Postern serves without a host check](/reference/ports#the-push-ingest-route).
    The Console prints the same example with its own placeholder host,
    `your-mac.tailnet.ts.net:8443`. Both host names are fake. Use yours.

    <Note>
      Leave this screen and the address stays. The connection page shows it again under
      **Delivery** → **Ingest endpoint**, with a copy control. Only the secret is one-time.

      Come back here once a connection exists and the card reads *A device is already pushing
      Health data here. Create another endpoint only if you’re adding a second device.* Tick
      *Yes — create a second ingest endpoint.* and the button reads **Create another
      endpoint**. Take it literally: that is a second connection for a second device, not a
      repair. To fix the phone you already have, use **Rotate secret** instead.
    </Note>
  </Step>

  <Step title="Install Health Auto Export on the iPhone" titleSize="h2" id="install-app">
    On the iPhone, open the App Store and install **Health Auto Export**. Open the app.

    On first launch it asks for access to Apple Health. Grant every category you want
    exported. Without that access every export is empty, and every step after this one
    still looks like it worked.

    The app carries an **Automations** tab. The next three steps happen there.

    <Note>
      Any program that can POST Postern's JSON body with the secret in a header works. The
      Console's Apple Health card carries that body under *The JSON body Postern expects*,
      with a **Copy the template** button. The same card describes an iPhone Shortcut built
      from a *Get Contents of URL* step. Four rules decide whether a payload lands:

      * Each `metrics[].name` becomes a metric type.
      * The metric named `sleep_analysis` becomes a sleep session.
      * Every entry under `workouts[]` needs its own `id`.
      * Dates are `yyyy-MM-dd HH:mm:ss ±hhmm`, not ISO. Postern skips a point it cannot
        date. It never stores one wrong.

      The rest of this page uses the app. That is the path that runs in production.
    </Note>
  </Step>

  <Step title="Build the metrics automation" titleSize="h2" id="metrics-automation">
    Open the **Automations** tab and press **New Automation**. Set **Automation Type** to
    **REST API**. The automation screen opens.

    Put the address you copied in step 1 into **URL**.

    Under **Add Headers**, add one header. Its name is `Authorization`. Its value is
    `Bearer <your secret>` — the word `Bearer`, one space, then the secret from step 1. A header
    named `X-PCI-Webhook-Secret` with the secret alone as its value works too, and it is the
    one the Console names.

    Under **Data Type Settings**, open **Data Type** and choose health metrics. An exported
    automation records that choice as `"exportDataType": "healthMetrics"`.

    <Warning>
      Set the export version to v2 before you save. Postern reads the shape v2 sends: one
      `data` object that holds `metrics` and `workouts`. Postern refuses a body that carries no
      `data` object at all. Nobody has ever tested a push from an older export version, so
      leave it on v2.
    </Warning>

    Set the rest to match the phone that runs in production. The names below are the ones
    Health Auto Export writes into an exported automation, with the values that phone exports:

    ```json theme={"system"}
    {
      "exportDestination": "restApi",
      "exportDataType": "healthMetrics",
      "exportFormat": "JSON",
      "exportVersion": "ExportVersion.v2",
      "exportFileLength": "day",
      "exportPeriod": "Default",
      "exportAggregation": "Default",
      "batchRequests": false,
      "aggregateData": true,
      "aggregateSleep": true,
      "includeHealthMetrics": true,
      "includeWorkouts": false,
      "includeCycleTracking": true,
      "includeMedications": true,
      "includeWorkoutMetadata": true,
      "includeRoutes": true,
      "includeECG": false,
      "includeHeartRateNotifications": false,
      "includeStateOfMind": false,
      "includeSymptoms": false,
      "requestTimeout": 86400
    }
    ```

    <Note>
      **Automations**, **New Automation**, **Automation Type**, **REST API**, **URL**, **Add
      Headers**, **Data Type Settings**, **Data Type** and **Timeout Interval** are the app's
      own words, read off a capture of the phone in production on 1 August 2026.

      Nobody has recorded what the app calls the settings in the block above. Those are the key
      names an exported automation uses. Match them by what they do, and trust your screen over
      this page. `requestTimeout` is the app's **Timeout Interval** field.
    </Note>

    Select every metric. A day of metrics stays small. The app writes each name into the
    automation one by one, and the phone in production lists 113 of them. It stores a list, not
    a wildcard, so an app update that adds a new metric does not pick it up on its own — come
    back and tick it.

    Leave `exportFileLength` on `day` and `batchRequests` off. Postern refuses any
    single push over 25 MB with `{"error":"payload too large"}`, and nothing in a refused push
    lands. One day at a time has never come close on the phone in production.

    <Note>
      Both aggregation settings change what an agent gets. Turn them on anyway — this is the
      pair the phone in production runs, and it has been healthy for weeks — but know the price.

      `aggregateData: true` changes the names. A point that carries a plain `qty` becomes one
      row under `<name>`. A point that carries Min, Avg or Max instead becomes one row per
      statistic present: `<name>_min`, `<name>_avg`, `<name>_max`. In production this happens to
      exactly one metric, heart rate: that store holds `heart_rate_min`, `heart_rate_avg` and
      `heart_rate_max`, and no `heart_rate` at all. An agent that asks for `heart_rate` gets
      nothing back. Postern tags every fanned row `aggregation: daily_total`, even when the
      value covers one minute. Never sum those rows across a day.

      `aggregateSleep: true` costs you the sleep numbers. Postern reads a night's length from
      two fields that the aggregated shape leaves at `0`, so a sleep session stores
      `time_asleep_seconds` `0`, `time_in_bed_seconds` `0` and an empty `efficiency_pct`. The
      start and the end are right, and the real total survives under `raw` as `totalSleep`, in
      hours. An agent works the night's length out from the start and the end.
    </Note>

    The automation exists once it appears in the automations list. Nothing reaches Postern
    until it runs.

    <Warning>
      An exported automation carries your live address and secret in its JSON, in plain text,
      on disk wherever you export it. Share one and you hand someone a live key to your health
      data. You cannot unshare a secret. Remove the URL and the headers before a config leaves
      your phone, or rotate the secret afterwards.
    </Warning>
  </Step>

  <Step title="Build the workouts automation" titleSize="h2" id="workouts-automation">
    In the same **Automations** tab, press **New Automation** and set **Automation Type** to
    **REST API** again. Copy every setting from step 3 — the same address, the same header, and
    the whole block. Then change the four that differ:

    ```json theme={"system"}
    {
      "exportDataType": "workouts",
      "includeHealthMetrics": false,
      "includeWorkouts": true,
      "requestTimeout": 60
    }
    ```

    On screen that is three moves: open **Data Type** and choose workouts, turn health metrics
    off in the include list, and turn workouts on. Then set **Timeout Interval** to 60.

    `includeWorkoutMetadata` and `includeRoutes` stay `true` in both automations. They do
    nothing in the metrics one, which carries no workouts.

    You now have two automations, both on **Automation Type** **REST API** and both on the same
    address: one exports health metrics, one exports workouts. Health Auto Export exports one
    data type per automation. With one automation, workouts never arrive.

    Sleep rides the metrics automation, not this one. Health Auto Export exports sleep as
    the metric `sleep_analysis`, and Postern turns that metric into sleep sessions.

    Postern stores workout routes and workout metadata in `raw` only. They are not columns
    an agent can query, and they make each push larger. Leave them on anyway: the phone in
    production has run `includeRoutes` and `includeWorkoutMetadata` on for weeks, one day per
    push, and no push has been refused on size.
  </Step>

  <Step title="Set the schedule, then run one automation by hand" titleSize="h2" id="first-push">
    Set each automation's schedule. Hourly is a good default; the phone in production pushes
    every hour. Then run one automation by hand in the **Automations** tab, rather than wait
    for the schedule.

    In the Console, open **Sources** → **Apple Health**. Until the first push lands the
    connection carries the badge **Awaiting first push**. When it lands, that badge
    disappears, the line reads **Last synced** with a time, and the caption beneath reads
    **Device controlled**. A healthy connection carries no badge, and no next time is
    predicted.

    Postern stamps **Last synced** on every push that arrives, even a push that stored no
    records.

    You can run the automation again safely. Health Auto Export's export windows overlap on
    purpose. Postern gives every sample, sleep session and workout a fixed id. The same
    data sent twice updates those rows. It never duplicates them.

    Keep the gap between pushes under 48 hours. After 48 hours without a push the connection
    reads **Stale**. **Stale** is advice, not a fault. Your phone sends data to Postern when it
    decides to. Postern cannot ask for it, so there is no **Sync now** button, and Postern
    never calls a device **Unreachable**.

    <Note>
      Nothing arrives, and nothing on the machine Postern runs on explains why. Every
      rejection at the ingest endpoint answers with the same `401` and the same body,
      `{"error":"unauthorized"}`. The answer never says which cause it was, so check all
      four:

      1. The address carries the connection id the Console printed.
      2. The header holds the secret exactly.
      3. The connection is not paused. **Pause receiving** stops new pushes. Data already
         stored still answers.
      4. The phone can reach the machine Postern runs on.

      [Every failure answers identically on purpose](/reference/ports#the-push-ingest-route).
      A request that never arrives leaves no trace. From the phone, a network problem and a
      wrong secret look the same. The two `curl` checks in
      [Open two ports to your own devices](/start/remote-access#your-devices) separate them.
    </Note>
  </Step>
</Steps>

## Confirm it works

* **Console → Sources**: one Apple Health connection, not two.
* The connection page: **Samples cached** reads above zero.
* The connection's **Delivery** panel: **Shared secret** reads `•••••••••••• set` and a relative
  time — `•••••••••••• set 3 days ago`, or `just now` on a secret you made this minute. It is not a
  calendar date. It must not read `no secret minted yet`, and the button beside it must read
  **Rotate secret**, not **Mint secret**.
* **Health Auto Export → Automations**: two automations, both on **Automation Type**
  **REST API** and both on the same address. One exports health metrics, one exports
  workouts.

## If something went wrong

| What you see                                                                   | What to do                                                                                                                                                                                                                                                                                                                                            |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{"error":"unauthorized"}`, or nothing arrives at all                          | Check all four causes; they answer identically. The address carries the connection id the Console printed; the header holds the secret exactly; the connection is not paused (**Resume receiving** on its **State** row); the phone reaches port 8443.                                                                                                |
| The connection reads **Last synced**, but **Samples cached** stays at 0        | The push arrived and stored nothing. Check the export version reads v2 in both automations, then run one by hand.                                                                                                                                                                                                                                     |
| `{"error":"payload too large"}`                                                | The push passed 25 MB, and none of it landed. Set the export file length back to one day (`exportFileLength: "day"`) and turn batch requests off (`batchRequests: false`) in both automations. Postern records nothing for a push it refuses on size — not even in the record. The app's own answer on the phone is the only place you will see this. |
| You did not copy the secret, or you shared an exported automation that held it | Open the connection → **Delivery** → **Rotate secret**. Every device on the old secret stops. Paste the new secret into both automations.                                                                                                                                                                                                             |

## What you have now

Your phone sends health data straight to Postern, with no cloud in between. Postern
refuses a push that does not carry the correct secret.

An agent can read what your phone has sent since you set this up: sleep, workouts and
every metric you selected. Two of those arrive renamed or thinned by the settings this
page prescribes — [what `aggregateData` and `aggregateSleep`
cost](#metrics-automation). There is no history to backfill. Health is read-only here —
a key granted **health** reads this data, and there is nothing to act on.

Your phone decides when data moves. If the data looks old, run the automation on your
phone. The secret lives in those two automations until you rotate it. Rotate it and
every device on the old secret stops. **Pause receiving** stops new pushes; data already
stored still answers. A paused connection refuses a push exactly as a wrong secret does.

## Next

<Columns cols={2}>
  <Card title="Connect Google and Gmail" href="/connect/google">
    your own Google Cloud app + one app password · about 20 minutes · weekly re-consent until you
    publish to production
  </Card>

  <Card title="Connect an agent" href="/start/connect-an-agent">
    a key you created, granted health · a few minutes, plus a restart of the agent · one config
    block to paste
  </Card>
</Columns>
