Sync to a Server You Own: The Self-Hosted Receiver Pattern and Its Quiet Failure Mode
Point your Apple Health export at an endpoint you run, and you own the pipeline. That is also the catch: nobody else is watching it. A receiver does not usually fail loudly, it just stops having anything new to say.
AI-assisted, human reviewed. How we use AI
Quick answer
The self-hosted receiver pattern means the phone POSTs its export to an HTTP endpoint on a machine you control, instead of syncing it through anyone's cloud. You get the full body, the headers and the retry rules, and you can log every request. What you also get is the whole monitoring burden: an endpoint that accepts bodies and writes nothing useful looks identical to a healthy one until the day you query it. The fix is small. Upsert on the documented keys, dedupe on webhook-id, and alert on the last time you actually stored something.
What the pattern actually is
It is a one-way push. The iPhone reads Apple Health, builds a JSON document, and POSTs it to a URL you set in the app. There is no account, no cloud relay and no vendor server in the path: the request goes from your phone to your box, over your LAN, over Tailscale, or over the public internet if you have given the endpoint a TLS front door. One URL receives everything, and the kind of document is named in a header rather than in the path, which is why a single handler can accept a nightly metrics run, an hourly vitals run, a workouts run, a sleep-session document, a time zone log and your in-app events timeline.
Practically, that means three kinds of integrators are well served by it: someone who wants their own history in Postgres, someone who wants a pipeline feeding a private dashboard, and someone whose AI agent needs the data to land somewhere durable before it reasons over it.
The receiver, in one command
You do not have to start from a blank handler. The app is tested against a reference receiver that is a single file with no dependencies (Node 18 or later) and checks the token, verifies the signature, skips duplicates, applies the storage rules and keeps the merged result in a JSON file:
node webhook-receiver.mjs --port 8787 --token YOUR_TOKEN --store ./data
# In the app: http://<your-mac>.local:8787/ingest (or a Tailscale address),
# same token, auth "Header"
Plain http is allowed only for local network and Tailscale addresses; anything reachable from the public internet needs https. Then press Test connection in the app. It sends the metrics envelope with no metrics and "_test": true, with the same headers, auth and signature as a real export, so a green result means the whole path works. Answer it with any 2xx; there is nothing to store.
The headers are the contract. Every request carries X-MetricBridge-Type (the document kind) and X-MetricBridge-Schema (the schema major version, currently 1, and additions never change it). webhook-id is unique per message and is your idempotency key. webhook-timestamp is unix seconds. webhook-signature appears when you have set an ingest token. X-MetricBridge-Replay appears only on a resend and carries the same rpl_… value on every request of that resend.
Verify the signature before you trust a body
The signature follows Standard Webhooks v1 with your ingest token as the secret: HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, base64, with a v1, prefix. Reject anything more than five minutes away from now, and compare in constant time. In Python:
import base64, hashlib, hmac
def verify(headers, raw_body, token, tolerance=300):
msg_id = headers["webhook-id"]
msg_ts = headers["webhook-timestamp"]
if abs(time.time() - int(msg_ts)) > tolerance:
return False
signed = f"{msg_id}.{msg_ts}.".encode() + raw_body
expected = base64.b64encode(
hmac.new(token.encode(), signed, hashlib.sha256).digest()
).decode()
return any(hmac.compare_digest(s, expected)
for s in headers["webhook-signature"].split())
Two details matter here. First, sign the raw body before your JSON parser touches it: a re-serialised dict will not produce the same bytes. Second, verify before you store, not after, because the point of the token is that only your phone gets to write rows.
The storage rules decide whether your history is right
This is the part most hand-rolled receivers get wrong, because the app is deliberately chatty: each scheduled run re-sends today and the two days before it (seven days worth after a time zone move), so the same data point arrives more than once by design.
Upsert on name + date. metrics.* upserts on metric name plus date, and workouts upsert on workout id. An append-only table double counts every run.
Sleep sessions are not upserts. Delete what you hold for the covered days, then insert. Sessions can split, merge or move when a source syncs late, so an upsert by id keeps stale ones.
Time zone log and events replace the whole document. An event missing from the list was deleted in the app.
Skip a webhook-id you already processed, and accept a type you do not know. Answer with any 2xx quickly and do the work afterwards.
One more shape note: the metrics envelope has no type key and is Health Auto Export compatible, while typed documents always carry one, plus an empty data.metrics array. That empty array is a courtesy. A receiver written for the metrics envelope only reads a sleep-session document as an update with nothing in it, rather than erroring on it.
The quiet failure mode
Six things go wrong, and none of them crash. They all look like a working pipeline.
- Insert instead of upsert. Every re-sent day adds a duplicate row. Your step totals creep upward and nothing tells you.
- Upserting sleep sessions by id. A late source sync splits one session into two, the old row survives, and a night of sleep gets counted twice.
- Ignoring the type header. Typed documents arrive as empty metric updates, so the sleep sessions you switched on silently never store.
- A 2xx that returns an HTML page is a failure. So is any 3xx, because redirects are never followed. A login wall, an SSO redirect or a cache page in front of your endpoint quietly converts every export into a failed delivery.
- Dropping the replay value. A resend re-imports days you already had, unless
webhook-iddedupe is doing its job. - No monitor. The receiver answers 200 and writes nothing, and the only signal is the absence of new rows, which nobody is looking at.
The sixth one is the one that costs a month. Add a check that reads your store and asks a single question: when did I last successfully write a document of each type I care about? If you keep the reference receiver's JSON store, one shell line is enough to catch a silent box:
# alert if nothing has landed in 30 hours
find ./data -type f -newermt '-30 hours' -print | grep . \
|| echo "receiver silent: no writes in 30h"
Pair that with the app side. If you do miss a window, Settings › Also send to your server › Resend past days re-reads Apple Health for a range of up to a year and sends the same bodies a normal export would, one request per week of data for each automation, with sleep sessions per week and the time zone log and events once at the end. Every request of that resend shares one X-MetricBridge-Replay value. Keep the app open and the iPhone unlocked while it runs: iOS only lets apps read Apple Health while the phone is unlocked.
Push to your server, pull to your machine
The webhook is the push half of the export surface, and the open-source MCP server is the pull half. The webhook lands a copy of your history wherever you want it kept, which is what a database, a dashboard or an always-on agent needs. The MCP server runs on the machine that already has the export, over stdio, exposes all 194 metrics through 22 tools (20 read-only, plus add_event and remove_event, which write one events file beside the export) and 23 prompts, and makes zero network calls. Same data, two directions, no vendor in either path.
Neither is a replacement for the other, and the reason to run the receiver is the same reason to run anything yourself: the failure mode is yours to see. Which is exactly why the monitoring line above is not optional. A pipeline you own is a pipeline you have to watch.
FAQ
Is a self-hosted receiver more private than a cloud sync?
It is more legible. Your health data goes from the phone to a destination you chose, and nothing reaches a server we operate, because there is no server of ours in the path. What changes with self-hosting is the trust boundary: you now secure, back up and monitor the endpoint yourself.
Why does my receiver see the same day three times?
By design. Each scheduled run re-sends today and the two days before it so late-arriving samples get corrected, and seven days after a time zone move. Upsert on metric name plus date and the repeats collapse into one row.
What happens if my endpoint returns a 2xx that renders an HTML page?
The app treats it as a failure, the same way it treats a 3xx, because redirects are never followed. A working receiver answers with a small 2xx JSON response and does the storage work afterwards.
Do I need the webhook if I already run the MCP server?
Not for agent queries. Install the receiver if you want your own durable copy of the history, a dashboard, or a pipeline that keeps running when your laptop is closed. The MCP server answers queries from the export on the machine it runs on; the webhook keeps a copy somewhere else.
Own the pipeline, and check the answers too
Ask your Apple Health data on the iPhone and read the working behind every answer, or send all 194 metrics to a server you run and let your own agent query them over MCP.
Where it goes: the app reads Apple Health read-only and writes to the destination you pick, whether that is a folder, your LAN or your own webhook. Your health data never reaches us or a cloud AI; the MCP server makes zero network calls. What you get: all 194 metrics, on-device Ask with a provenance card on every answer, the Weekly Brief, and the Mac and Apple Watch apps. Price: $24.99 one time after a 7-day free trial, not a subscription. Wellness, not medical advice.