DEV Community

Philip D'Souza
Philip D'Souza

Posted on

The self-hosted Apple Health receiver pattern and its quiet failure mode

Point your Apple Health export at an endpoint you run. You get the full body, the headers and the retry rules, and you own the pipeline. The catch is that nobody else is watching it, and a self-hosted receiver does not fail loudly. It just stops having anything new to say.

The pattern

The phone reads Apple Health, builds a JSON document, and POSTs it to a URL you set in the app. No account, no cloud relay, no vendor server in the path. The document kind travels in a header (X-MetricBridge-Type), not the path, so one handler accepts nightly metrics, hourly vitals, workouts, sleep sessions, a time zone log and your events timeline.

The contract you have to honour

Every request carries X-MetricBridge-Type, X-MetricBridge-Schema (currently 1), webhook-id (your idempotency key), and webhook-timestamp. With an ingest token set, webhook-signature follows Standard Webhooks v1: HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, base64 with a v1, prefix. Sign the raw body before your JSON parser touches it, verify before you store, and reject anything more than five minutes off.

The storage rules decide whether your history is right

The app is deliberately chatty: each scheduled run re-sends today and the two days before it, so the same data point arrives more than once by design.

  • Upsert metrics on name plus date. Workouts upsert on workout id. Append-only tables double count every run.
  • Sleep sessions are not upserts. Delete what you hold for the covered days, then insert. Sessions split and merge when a source syncs late, so an upsert by id keeps stale rows.
  • The time zone log and events replace the whole document.
  • Skip a webhook-id you have already processed, and accept a type you do not know.

The quiet failure mode

Six things go wrong and none of them crash. Inserting instead of upserting. Upserting sleep sessions by id. Ignoring the type header, so typed documents arrive as empty metric updates. A 2xx that returns an HTML login page, which is a failure, and so is any 3xx, because redirects are never followed. Dropping the replay value on a resend. And no monitor at all: the receiver answers 200 and writes nothing, and the only signal is the absence of new rows.

The sixth one costs a month. Add one check that reads your store and asks a single question: when did I last successfully write a document of each type I care about?

The webhook is the push half of the export surface. The zero-dependency MCP server is the pull half: same data, two directions, no vendor in either path.

Full write-up, with the reference receiver and a Python signature-verification snippet: https://dub.sh/lhHWHNv

Top comments (0)