REST API Setup Guide
This guide walks through setting up a server to receive health data from Health Export Pro.
Overview
Health Export Pro sends a JSON POST request to your server whenever it syncs. You provide:
- A server URL (e.g.
https://my-server.com) - An endpoint path (default:
/api/health-data) - Optional authentication (Bearer token or custom header)
The app handles scheduling, retries, and background delivery. Your server just needs to accept JSON and return a 2xx response.
Quick Start
1. Set Up a Receiver
Create a minimal server that accepts POST requests. Here’s a Node.js example:
const express = require("express");
const fs = require("fs");
const app = express();
app.use(express.json({ limit: "50mb" }));
app.post("/api/health-data", (req, res) => {
const { exportDate, startDate, endDate, syncSource, data, workoutRoutes } =
req.body;
// Count total array items in data sections
const types = Object.keys(data);
const total = types.reduce(
(sum, key) => sum + (Array.isArray(data[key]) ? data[key].length : 0),
0,
);
const workoutRouteCount = workoutRoutes
? Object.values(workoutRoutes).reduce(
(sum, routes) => sum + (Array.isArray(routes) ? routes.length : 0),
0,
)
: 0;
console.log(
`[${new Date().toISOString()}] ${syncSource}: ${total} array items across ${types.length} sections, range ${startDate} -> ${endDate}, ${workoutRouteCount} route sets`,
);
// Option A: Write to file
const filename = `export-${Date.now()}.json`;
fs.writeFileSync(filename, JSON.stringify(req.body, null, 2));
// Option B: Insert into your database
// await db.insert("health_samples", req.body.data);
res.json({ ok: true, count: total });
});
app.listen(3000, () => console.log("Health data receiver on :3000"));
Or with Python:
from flask import Flask, request, jsonify
import json, os
from datetime import datetime
app = Flask(__name__)
@app.route("/api/health-data", methods=["POST"])
def receive():
payload = request.get_json()
data = payload.get("data", {})
total = sum(len(v) for v in data.values() if isinstance(v, list))
workout_routes = payload.get("workoutRoutes", {})
workout_route_count = sum(
len(v) for v in workout_routes.values() if isinstance(v, list)
) if isinstance(workout_routes, dict) else 0
print(
f"[{datetime.now()}] {payload.get('syncSource')}: "
f"{total} array items, {workout_route_count} route sets, "
f"range {payload.get('startDate')} -> {payload.get('endDate')}"
)
# Write to file
filename = f"export-{int(datetime.now().timestamp())}.json"
with open(filename, "w") as f:
json.dump(payload, f, indent=2)
return jsonify({"ok": True, "count": total})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3000)
2. Make Your Server Reachable
Your iPhone must be able to reach the server:
| Setup | URL Example | Notes |
|---|---|---|
| Same Wi-Fi network | http://192.168.1.100:3000 |
Find your computer’s local IP |
| VPS / cloud server | https://my-server.example.com |
HTTPS required for non-local |
| Tunnel (ngrok, Tailscale) | https://abc123.ngrok.io |
Good for testing |
HTTPS: Apple requires HTTPS for external connections. Local network (192.168.x.x, 10.x.x.x) can use HTTP.
3. Configure in App
- Open Health Export Pro → Settings → REST API Destination
- Enter your Server URL (e.g.
https://my-server.example.com) - Set Endpoint Path (default
/api/health-data) - Choose Auth Type:
- None — no authentication header
- Bearer — sends
Authorization: Bearer {your_key} - Custom Header — sends
{header_name}: {your_key}
- Tap Test Connection to verify
4. Trigger a Sync
- Manual: Go to Export screen → tap Upload to Server
- Automatic (Pro): Background sync runs every ~15 minutes when new HealthKit data is available
Check Sync Log in Settings to see results.
Server Requirements
| Requirement | Details |
|---|---|
| Method | POST |
| Content-Type | application/json |
| Body size limit | At least 50 MB (large date ranges can produce big payloads) |
| Response | Any 2xx status code |
| Timeout | The app waits up to 30 seconds for a response |
Optional Response Body
If your server returns a JSON body with a record count, the app shows it in the Sync Log:
{ "count": 42 }
Recognized fields: count, total, records, or inserted.measurements + inserted.medications + inserted.workouts.
For chunked or richer receivers, returning an object like this is also useful:
{
"ok": true,
"inserted": {
"measurements": 1250,
"medications": 12,
"workouts": 3,
"workoutRoutes": 4821
}
}
Authentication
Bearer Token
The app sends:
Authorization: Bearer your-secret-key-here
Your server should validate this header and reject unauthorized requests with 401.
Custom Header
For servers that use non-standard auth headers (e.g. X-API-Key), configure both the header name and value in the app:
X-API-Key: your-secret-key-here
Payload Reference
See the API reference for the complete payload schema, or import the OpenAPI YAML into Postman / your API tooling. The important special sections are:
workoutsfor workout samplesmedicationDoseEventsfor timestamped medication logsmedicationsfor medication definitionsworkoutRoutesfor GPS route points keyed by workout UUIDcharacteristicsfor static HealthKit characteristics
Database Examples
PostgreSQL (via Node.js)
const { Pool } = require("pg");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.post("/api/health-data", async (req, res) => {
const { data, exportDate } = req.body;
let count = 0;
for (const [typeId, samples] of Object.entries(data)) {
if (!Array.isArray(samples)) continue;
for (const sample of samples) {
await pool.query(
`INSERT INTO health_samples (uuid, type_id, value, unit, start_date, end_date, metadata, export_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (uuid) DO NOTHING`,
[
sample.uuid,
typeId,
sample.value,
sample.unit || null,
sample.startDate,
sample.endDate,
JSON.stringify(sample.metadata || {}),
exportDate,
],
);
count++;
}
}
res.json({ count });
});
SQLite (via Python)
import json
import sqlite3
db = sqlite3.connect("health.db")
db.execute("""
CREATE TABLE IF NOT EXISTS samples (
uuid TEXT PRIMARY KEY,
type_id TEXT,
value REAL,
unit TEXT,
start_date TEXT,
end_date TEXT,
metadata TEXT
)
""")
@app.route("/api/health-data", methods=["POST"])
def receive():
payload = request.get_json()
count = 0
for type_id, samples in payload.get("data", {}).items():
if not isinstance(samples, list):
continue
for s in samples:
if "uuid" not in s or "startDate" not in s or "endDate" not in s:
continue
db.execute(
"INSERT OR IGNORE INTO samples VALUES (?, ?, ?, ?, ?, ?, ?)",
(s["uuid"], type_id, s.get("value"), s.get("unit"),
s["startDate"], s["endDate"], json.dumps(s.get("metadata", {})))
)
count += 1
db.commit()
return jsonify({"count": count})
This SQLite example intentionally handles only sample rows that include uuid, startDate, and endDate. Special sections like workouts, medicationDoseEvents, medications, and workoutRoutes need dedicated handling.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| “Connection failed” | Server unreachable | Check URL, ensure same network or HTTPS for remote |
| “401 Unauthorized” | Auth mismatch | Verify API key and auth type match server config |
| “413 Payload Too Large” | Server body limit too low | Increase to 50+ MB (e.g. express.json({ limit: "50mb" })) |
| “Timeout” | Server too slow | Optimize DB writes, use async/batch inserts |
| Missing data types | Types disabled in app | Check Data Types screen in app |
| Duplicate records | No dedup on server | Use an idempotent key such as UUID where available, or a table-specific upsert key |
| Sync not triggering | Free tier (manual only) | Upgrade to Pro for automatic background sync |