← MetricBridge

Webhook: what MetricBridge sends to your server

Point MetricBridge at your own endpoint (Settings › Webhook) and every export is POSTed to it as JSON. This page is the contract: every body, every header, how to tell them apart, and how to verify them.

Last updated 4 October 2026 · applies to MetricBridge 1.11 and later · OpenAPI 3.1 · reference receiver

On this page

What arrives

One URL receives everything. Each request carries exactly one kind of document, named in the X-MetricBridge-Type header.

TypeWhenBody
metrics.dailyDaily Metrics and Sleep automationsMetrics envelope, one point per metric per day. Nightly sleep totals arrive here as sleep_analysis.
metrics.intradayVitals (Intraday) automationMetrics envelope, one point per hour
workoutsWorkouts automationMetrics envelope with data.workouts
sleep.sessionsWith the Sleep automation, when Sleep sessions is onTyped document
timezone.changesWhen it changes, at most daily, when Time zone changes is onTyped document
eventsWhen it changes, at most daily, when Events is onTyped document

The three typed documents are off until you switch them on in Settings › Also send to your server, so a server written for the metrics envelope never sees a body it does not expect. They are sent only after the same run's metrics were accepted. The metrics envelope is the same body MetricBridge has always sent (Health Auto Export compatible) and has no type key; typed documents always have one.

Typed documents also carry an empty data.metrics array. A receiver that only understands the metrics envelope reads them as an update with nothing in it, rather than an error.

Headers

HeaderMeaning
X-MetricBridge-TypeThe document kind (table above)
X-MetricBridge-SchemaSchema major version, currently 1. Additions never change it.
webhook-idUnique per message (msg_…). Your idempotency key: ignore one you already processed.
webhook-timestampUnix seconds when it was sent
webhook-signaturev1, + base64 HMAC-SHA256, when an ingest token is set. See below.
X-MetricBridge-ReplayOnly on a resend: the same rpl_… value on every request of that resend
automation-nameThe automation that produced it, in English (unchanged from earlier versions)
x-health-tokenYour ingest token, with the Header auth method. Bearer, query string and body are the alternatives.

Verifying a request

The signature follows Standard Webhooks v1 with your ingest token as the secret: HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, base64, prefixed v1,. Verify against the raw bytes before parsing, compare in constant time, and reject timestamps more than five minutes away from now. When export encryption is on for your server, the signature covers the encrypted envelope exactly as sent.

// Node
import crypto from 'node:crypto'
function verified(token, headers, rawBody) {
  const id = headers['webhook-id'], ts = headers['webhook-timestamp']
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const expected = 'v1,' + crypto.createHmac('sha256', token).update(`${id}.${ts}.`).update(rawBody).digest('base64')
  return headers['webhook-signature'].split(' ').some((s) =>
    s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))
}
# Python
import base64, hashlib, hmac, time
def verified(token: str, headers, raw_body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(token.encode(), f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(mac).decode()
    return any(hmac.compare_digest(s, expected) for s in headers["webhook-signature"].split())

The official Standard Webhooks libraries work too: give them the secret whsec_ followed by the base64 of your token.

Metrics envelope

metrics.daily, metrics.intraday and workouts share this shape. Dates use the phone's zone at export time (yyyy-MM-dd HH:mm:ss Z). Each scheduled run re-sends today and the two days before it (seven after a time zone move), so the same point arrives more than once: upsert it.

{ "data": {
    "metrics": [
      { "name": "sleep_analysis", "units": "hr",
        "data": [ { "date": "2026-10-03 00:00:00 +0100", "qty": 7.62, "totalSleep": 7.62,
                    "asleep": 7.62, "core": 4.1, "deep": 1.21, "rem": 1.88 } ] }
    ],
    "workouts": [ { "id": "6F1C2A8E-…", "name": "Running", "start": "2026-10-03T07:05:11Z",
                    "end": "2026-10-03T07:41:40Z", "duration": 2189 } ] } }

Sleep sessions (sleep.sessions)

Every sleep session whose waking day falls in coverage.fromDay to coverage.toDay, with start and end in the zone it was recorded in, stage totals, every recorded sample as a timeline, and a nap flag.

{ "type": "sleep.sessions", "schema": 1, "id": "msg_…", "app": "1.11", "sentAt": "2026-10-04T07:15:02Z",
  "data": { "metrics": [],
    "coverage": { "fromDay": "2026-10-02", "toDay": "2026-10-04", "mode": "replace_days" },
    "sleepSessions": [ {
      "id": "slp_1790979000", "day": "2026-10-03",
      "start": "2026-10-02T23:10:00+01:00", "end": "2026-10-03T06:52:00+01:00",
      "hours": 7.37, "main": true, "nap": false,
      "tz": "Europe/London", "tzSource": "sample", "staged": true,
      "stages": { "core": 4.02, "deep": 1.15, "rem": 1.9, "awake": 0.33 },
      "sources": ["com.apple.health.81C4…"],
      "segments": [ { "stage": "deep", "start": "2026-10-03T00:31:00+01:00",
                      "end": "2026-10-03T01:40:00+01:00", "source": "com.apple.health.81C4…" } ] } ] } }

Time zone changes (timezone.changes)

The phone's full time zone log. Each change says: from this local day onward the phone was in this zone. A day on which a change lands was not 24 hours long, so treat it as a travel day.

{ "type": "timezone.changes", "schema": 1, "id": "msg_…", "app": "1.11", "sentAt": "…",
  "data": { "metrics": [], "timeZones": { "mode": "replace", "currentTz": "Europe/London",
    "changes": [ { "from": "2026-09-26", "tz": "America/New_York", "utcOffsetMin": -240 },
                 { "from": "2026-10-01", "tz": "Europe/London", "utcOffsetMin": 60 } ],
    "note": "Timezone history is recorded from the first app version that shipped this file onward. …" } } }

Events (events)

The timeline you keep in the app (Settings › Events): medication changes, visits, habits, trips. Trips you log have type: "travel"; detected time zone moves are in timezone.changes instead.

{ "type": "events", "schema": 1, "id": "msg_…", "app": "1.11", "sentAt": "…",
  "data": { "metrics": [], "events": { "mode": "replace", "events": [
    { "id": "0E9B5C1A-…", "date": "2026-09-26", "endDate": "2026-09-30", "type": "travel", "title": "New York" } ] } } }

Storing it correctly

TypeRule
metrics.*Upsert on metric name + date
workoutsUpsert on workout id
sleep.sessionsDelete 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 alone keeps stale ones.
timezone.changes, eventsReplace the whole document. An event missing from the list was deleted.
AnySkip a webhook-id you already processed. Accept, and ignore, a type you do not know.

Answer with any 2xx quickly and do the work afterwards. A 3xx is treated as a failure (redirects are never followed), and so is a 2xx that returns an HTML page.

Resending past days

If your server missed something, open Settings › Also send to your server › Resend past days, pick a range (up to a year) and tap Resend. MetricBridge reads Apple Health again for those days 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 carries the same 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.

Schemas & examples

JSON Schema 2020-12, one file per body, validated in CI against these examples and against every request captured from the app in an end-to-end run.

Versioning: new optional fields can appear at any time, so do not reject unknown keys. A breaking change would ship under a new X-MetricBridge-Schema value and a new /v2/ path.

Reference receiver

webhook-receiver.mjs is a single file with no dependencies (Node 18 or later) that accepts all of the above, checks the token and signature, skips duplicates, applies the storage rules and keeps the merged result in a JSON file. It is the receiver the app is tested against.

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"

Limits

MetricBridge · local-first, read-only. · Home · MCP server · Privacy · Support · Security