json or csv / any https url / runs with the app closed / free
Automations: your data, pushed on a schedule.
Marrow can POST a file of your health data to any URL you choose, on the cadence you choose, from the phone directly. This page is the reference: how to set one up, what each payload looks like, and how to write something that receives it.
All Marrow guides: MCP · Self-hosted mirror · Automations · Files · Support
What an automation is
A saved job in Export, Automated: one kind of data, one format, one date range, one URL, one cadence. Marrow builds the file from its verified store and POSTs it to the URL with the app closed, after every sync commit and on each background refresh, whenever the cadence has elapsed. Nothing passes through us: the phone talks straight to your endpoint.
Setting one up
- Export tab, Automated, New automation.
- Name it, paste the https URL it should POST to. Add any headers your receiver needs (an
Authorizationbearer token, anX-Gitlab-Token, anything). - Pick the kind: Health metrics, Workouts, Strength log, or Nutrition diary. One automation carries one kind; make one per kind you want.
- Pick JSON or CSV, the date range, and the cadence (minutes, hours or days).
- Turn it on. Run now sends a first payload so you can check the receiver before waiting for the schedule.
Date ranges
- Since last run (default): everything since the last successful run, minus a two-day overlap. HealthKit keeps filling in a day after it ends, so the overlap re-sends recently completed days. Keep the latest value per metric and day on your side and the repeats are harmless. A receiver that was down for a while gets every missed day on the first run that succeeds.
- Today, Last 7 / 30 / 90 days, Last year: fixed rolling windows, re-sent in full each run. Choose these when you would rather have a self-healing snapshot than a delta.
Payload shapes
Health metrics (daily totals, JSON)
The same shape as Health Auto Export's, so receivers written for that app work unchanged. Values are in the stored SI unit named in units (kg, m, mL, degC, kcal, count); convert on your side if you want imperial.
{"data": {"metrics": [
{"name": "step_count", "units": "count",
"data": [{"date": "2026-09-11", "qty": 8746}]},
{"name": "body_mass", "units": "kg",
"data": [{"date": "2026-09-11", "qty": 101.3}]},
{"name": "sleep_analysis", "units": "hr",
"data": [{"date": "2026-09-11", "totalSleep": 7.2, "deep": 1.1, "rem": 1.6, "core": 4.3, "awake": 0.2,
"sleepStart": "2026-09-10 23:36:00", "sleepEnd": "2026-09-11 07:02:00"}]}
]}}
Raw samples (the Raw samples grouping) send every sample with its start and end time instead of a daily total. CSV metrics come as the wide table described on the files page.
Workouts
{"data": {"workouts": [
{"activity": "Running", "start": "2026-09-11T18:43:41Z", "end": "2026-09-11T19:13:53Z",
"duration_min": 30.2, "kcal": 512, "distance_mi": 3.06, "avg_hr": 174, "source": "Apple Watch"}
]}}
Strength log
Every set, plus per-exercise progress and per-muscle-group volume. Weights are in the unit you chose in Settings, Units; the unit field says which.
{"data": {"unit": "lb",
"sets": [{"time": "2026-09-11T17:02:00Z", "session": "Push day", "exercise": "Bench Press",
"set": 1, "weight": 185, "reps": 8, "est_1rm": 229.6}],
"exercises": [{"exercise": "Bench Press", "best_est_1rm": 229.6,
"sessions": [{"date": "2026-09-11T17:02:00Z", "sets": 3, "top_weight": 185, "top_reps": 8, "est_1rm": 229.6, "volume": 3885}]}],
"muscle_groups": [{"group": "Chest", "volume": 3108.0, "sets": 3}]
}}
Nutrition diary
{"data": {"days": [
{"day": "2026-09-11", "entries": [
{"meal": "breakfast", "name": "Oatmeal", "grams": 250, "kcal": 300,
"protein_g": 10, "carbs_g": 54, "fat_g": 5, "logged_at": "2026-09-11T12:10:00Z",
"nutrients": {"dietary_fiber": 8.0, "dietary_sugar": 1.2}}
]}
]}}
Writing a receiver
Anything that accepts an HTTPS POST and answers 2xx works: a webhook service, a serverless function, a script on a home server. Three rules keep it robust:
- Latest wins. Store values keyed by (metric, day) or by the entry's own identity and overwrite on repeat. Windows overlap by design.
- Archive the raw body first. Then parse. A parser bug should never cost you the data.
- Answer fast. Marrow waits up to the timeout you set (default 60 s) and marks the run failed otherwise; failed runs are retried on the next cadence tick from the last successful window.
A twelve-line Python receiver that archives every post:
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
class H(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
with open(f"marrow-{int(time.time())}.json", "wb") as f:
f.write(body)
self.send_response(200); self.end_headers()
HTTPServer(("0.0.0.0", 8798), H).serve_forever()
Put it behind HTTPS (a Cloudflare Tunnel, Tailscale Funnel, or any reverse proxy) and point the automation at it. If what you want is a queryable copy rather than a folder of files, the self-hosted mirror is that receiver already written.
Good to know
- Automations run only when iOS lets the app run: after a sync commit, on foreground refresh, and during background refresh. A phone that stays locked all day sends when it is next unlocked; the since-last-run window covers the gap.
- Every run is logged in the automation's own history with the response code, so a misconfigured URL is visible in the app, not just on your server.
- Duplicate an automation to reuse its headers for another kind.