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:

  1. A server URL (e.g. https://my-server.com)
  2. An endpoint path (default: /api/health-data)
  3. 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

  1. Open Health Export Pro → SettingsREST API Destination
  2. Enter your Server URL (e.g. https://my-server.example.com)
  3. Set Endpoint Path (default /api/health-data)
  4. Choose Auth Type:
    • None — no authentication header
    • Bearer — sends Authorization: Bearer {your_key}
    • Custom Header — sends {header_name}: {your_key}
  5. Tap Test Connection to verify

4. Trigger a Sync

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:

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