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
One URL receives everything. Each request carries exactly one kind of document, named in the
X-MetricBridge-Type header.
| Type | When | Body |
|---|---|---|
metrics.daily | Daily Metrics and Sleep automations | Metrics envelope, one point per metric per day. Nightly sleep totals arrive here as sleep_analysis. |
metrics.intraday | Vitals (Intraday) automation | Metrics envelope, one point per hour |
workouts | Workouts automation | Metrics envelope with data.workouts |
sleep.sessions | With the Sleep automation, when Sleep sessions is on | Typed document |
timezone.changes | When it changes, at most daily, when Time zone changes is on | Typed document |
events | When it changes, at most daily, when Events is on | Typed 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.
data.metrics array. A receiver that only
understands the metrics envelope reads them as an update with nothing in it, rather than an error.| Header | Meaning |
|---|---|
X-MetricBridge-Type | The document kind (table above) |
X-MetricBridge-Schema | Schema major version, currently 1. Additions never change it. |
webhook-id | Unique per message (msg_…). Your idempotency key: ignore one you already processed. |
webhook-timestamp | Unix seconds when it was sent |
webhook-signature | v1, + base64 HMAC-SHA256, when an ingest token is set. See below. |
X-MetricBridge-Replay | Only on a resend: the same rpl_… value on every request of that resend |
automation-name | The automation that produced it, in English (unchanged from earlier versions) |
x-health-token | Your ingest token, with the Header auth method. Bearer, query string and body are the alternatives. |
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.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)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…" } ] } ] } }
sleep_analysis total uses, so the two always agree.
hours counts overlapping sources once.main is the longest session that overlaps 18:00 the
evening before to 12:00 that day (or simply the longest, if none does, as for night-shift sleep). Every other
session that day has nap: true. A night broken by more than 3 hours awake comes through as a
main session plus a second one.segments lists every Apple Health sleep sample overlapping the session,
unclipped, from every source: core, deep, rem, asleep
(unstaged), awake, in_bed. If two apps record the same night, filter by
source before adding durations. Apple Watch naps under about 3 hours have no stages.tz comes from the samples' own time zone (Apple Watch records it;
tzSource: "sample"), otherwise the phone's zone at export time ("device").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)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" } ] } } }
| Type | Rule |
|---|---|
metrics.* | Upsert on metric name + date |
workouts | Upsert on workout id |
sleep.sessions | 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 alone keeps stale ones. |
timezone.changes, events | Replace the whole document. An event missing from the list was deleted. |
| Any | Skip 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.
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.
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.
webhooks)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.
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"
http is allowed only for local network and Tailscale addresses; anything public needs https.