Releasesv0.1.0

Quickstart

Run an instance, upload one heart rate reading and read it back normalized. About five minutes from a clean machine.

You will start Open Health Sync and its database with Docker Compose, create a tenant, upload one Apple Health reading the way an iOS app would, and read it back in canonical units.

What you need

Docker with Compose v2.24 or later, plus curl, jq and openssl. Compose pulls the published image ghcr.io/soma-intelligence/open-health-sync (amd64 and arm64), so a Raspberry Pi works too.

Get the compose files

git clone https://github.com/soma-intelligence/open-health-sync.git && cd open-health-sync

Every command below runs from the repository root.

Create the configuration

Copy the example file and put a fresh encryption key in it. The key seals provider OAuth tokens at rest: keep it with your backups.

cp .env.example .env
sed -i.bak "s|^OHS_ENCRYPTION_KEY=.*|OHS_ENCRYPTION_KEY=$(openssl rand -base64 32)|" .env

Start the stack

docker compose --env-file .env -f deploy/docker-compose.yml up -d --wait

--wait returns once Postgres and ohs are healthy. The ohs container applies the database migrations, then serves the API on port 8080.

Create a tenant

A tenant is a customer of the instance: your backend. Its API key is printed once.

docker compose --env-file .env -f deploy/docker-compose.yml exec ohs /ohs admin tenant create --name dev
Output
tenant dev created (id 7b1c...)
API key, shown only this once, store it now:
ohs_sk_...

Keep it in your shell:

export OHS=http://localhost:8080
export KEY=ohs_sk_...    # the key printed above

Create a user and a token for their app

Your backend creates an end user, then mints a short-lived token their phone app holds. This one may only upload.

USER_ID=$(curl -s -X POST $OHS/v1/users -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"external_ref":"demo"}' | jq -r .id)
TOKEN=$(curl -s -X POST $OHS/v1/users/$USER_ID/tokens -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"scopes":["upload"]}' | jq -r .token)

Upload one reading

This is what an iOS app sends: one gzip NDJSON part per HealthKit type. Here, a single heart rate reading of 72 count/min.

DAY=$(date -u +%Y-%m-%d); NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '{"quantityType":"HKQuantityTypeIdentifierHeartRate","startDate":"%s","endDate":"%s","value":72,"unit":"count/min","sourceName":"Demo Watch","metadata":{"HKTimeZone":"UTC"}}\n' "$NOW" "$NOW" \
  | gzip | curl -s -X POST $OHS/v1/ingest/apple-health \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/gzip" \
    -H "X-OHS-Type: HKQuantityTypeIdentifierHeartRate" -H "X-OHS-Day: $DAY" \
    -H "X-OHS-Batch: demo1" -H "X-OHS-Part: 0" --data-binary @-
Response
{"key":"raw/<tenant>/<user>/apple_health/2026-10-11/demo1_0.ndjson.gz","duplicate":false}

The payload is now in the raw store, verbatim, and a job is queued. Send the same bytes again and the answer says "duplicate":true: nothing is stored twice.

Read it back

Processing is asynchronous. The coverage report says when it is done: wait for pending_jobs to reach 0.

curl -s "$OHS/v1/users/$USER_ID/coverage" -H "Authorization: Bearer $KEY" \
  | jq '{pending_jobs, parked_jobs, rejected_records}'
Response
{ "pending_jobs": 0, "parked_jobs": 0, "rejected_records": 0 }

Then read the sample back:

curl -s "$OHS/v1/users/$USER_ID/samples?type=heart_rate" -H "Authorization: Bearer $KEY" \
  | jq '.data[0]'
Response
{
  "type": "heart_rate",
  "value": 72,
  "unit": "bpm",
  "start_date": "2026-10-11T09:30:00Z",
  "end_date": "2026-10-11T09:30:00Z",
  "source_name": "Demo Watch",
  "metadata": {
    "timezone": "UTC",
    "HKTimeZone": "UTC",
    "sourceName": "Demo Watch",
    "original_unit": "count/min",
    "original_value": 72
  },
  "provider": "apple_health",
  "source": "apple_health",
  "ingested_at": "2026-10-11T09:30:04.820258Z"
}

count/min became heart_rate in bpm, what Apple Health sent is kept in metadata, and the record carries its provenance. A few empty fields are left out above.

Check the instance

ohs doctor checks the configuration, the database, the raw store and every connector, one line each:

docker compose --env-file .env -f deploy/docker-compose.yml run --rm ohs doctor
Output
ok    config
ok    database
ok    raw store
ok    public url (http://localhost:8080)
skip  connector withings: disabled (no keys)
ok    connector apple_health

Data lives in the pgdata and raw volumes; docker compose ... down keeps them.

Where to next

Edit on GitHub

On this page