Skip to content

First sync

You need a running server (Quick start) and a device token (Device token).

If the phone will reach the server from outside your home network, you also need the certificate work in TLS and mutual TLS first — the public interface rejects connections without a client certificate at the TLS handshake, before any token is examined. If the phone is on the same LAN or on your VPN, you can do a first sync against the LAN interface and add certificates afterwards.

Sending is off until you switch it on. Nothing has been uploaded so far, and if you never switch it on, nothing ever will be.

In the app’s settings, in the section for sending data to your own server:

  1. enable sending;
  2. enter the base URL of your server, including the /v1 prefix — for example https://helsa.example.net/v1;
  3. paste the device token;
  4. test the connection — the button is on that screen and sends one request without saving anything;
  5. save;
  6. ⚠️ restart the app. The connection is built at launch, so a saved setting does not take effect until then. The app says so under the field; this page used to leave it out, and the symptom is a device that never registers.

The app’s “Your own server” screen, with sending switched off by default

This is the screen those steps happen on. It opens with Sending switched off — that is the shipping default, not a screenshot of a fresh install: the data stays on the phone until you give it an address.

The first sync is the big one; every later sync is a delta.

  1. HealthKit is queried with an anchored query. The anchor is an opaque cursor meaning “everything up to here has been handled”.
  2. The delta is cut into chunks — a few thousand items each, counting samples, workouts, sleep segments, activity summaries, route points, and deletions together.
  3. Each chunk is POSTed to /v1/ingest. The server replies 202 Accepted after it has taken the chunk, and processes it asynchronously through the queue.
  4. Only after the 202 does the app advance its anchor, and only for that chunk.

That last point is the whole design. If the connection dies mid-upload, the anchor still points at the last acknowledged chunk, so the next attempt resumes there — nothing is lost, nothing is uploaded twice in a way that matters. Ingestion is idempotent anyway: every item carries its HealthKit source_uuid, which is the deduplication key.

A phone that has been offline for weeks catches up by repeating this loop. Details: API — Ingest.

Did a device register?

Terminal window
# Through the proxy, which is the only thing that publishes a port. `--cert`/`--key`
# are your client certificate — the API is behind mutual TLS.
curl -s --cert client.crt --key client.key --cacert ca.crt \
-H "Authorization: Bearer $TOKEN" \
https://helsa.example.net/v1/devices | jq

You should see one entry with a recent last_seen_at. That timestamp is also what the staleness alert watches.

Did samples arrive?

Terminal window
curl -s --cert client.crt --key client.key --cacert ca.crt \
-H "Authorization: Bearer $TOKEN" \
'https://helsa.example.net/v1/summary?range=week&metrics=stepCount,heartRate&tz=Europe/Budapest' | jq

What does the database say?

Terminal window
docker compose exec timescaledb psql -U helsa -d helsa -c \
"SELECT data_type, count(*), min(ts), max(ts) FROM samples GROUP BY 1 ORDER BY 2 DESC;"

Is the worker keeping up?

Terminal window
docker compose logs -f worker

The worker logs what each batch contained — samples, workouts, sleep_segments, activity_summaries, deletions — beside its size. ⚠️ Those are items received, not rows changed: the writes are upserts, so a chunk replayed after a failed acknowledgement reports the same numbers and stores nothing new. A growing queue with an idle worker means the worker cannot reach the database or the broker — check /readyz.

The dashboard’s Today page after a first sync

⚠️ Note what this picture actually shows: yesterday arrived — rings, a workout, a night’s sleep — and today has not, so today’s figures are dashes rather than zeros. That is the normal state between syncs, and it is the distinction the whole app is built on: a missing measurement is never drawn as a measured nought.

Symptom Likely cause Fix
App reports a TLS or “cannot connect” error The CA certificate is installed on the phone but not trusted. Installing a root CA and trusting it are two separate steps on iOS. Settings → General → About → Certificate Trust Settings, and enable it. See TLS and mutual TLS.
Connection refused at the TLS handshake, no HTTP status No client certificate, or one signed by a different CA. Reimport the .p12. Confirm with openssl verify -CAfile ca.crt client.crt.
401 Token missing, mistyped, revoked, or signed with a different HELSA_JWT_SECRET. Issue a fresh token and paste it again.
413 The chunk exceeded the server limit. The app should shrink its chunk and retry; the 202 body advertises max_items. If it persists, the client-side chunk size is too large.
202 but nothing in the database The worker is down, or the queue is unreachable. docker compose ps, docker compose logs worker, curl /readyz.
Data arrives but daily totals look wrong Time zone. Daily buckets are computed in a specific zone. Set time_zone in settings to a valid IANA zone, or pass tz= explicitly. See API conventions.
Steps look roughly doubled Both the iPhone and the Watch recorded them, and the server sums everything it received. ⚠️ Not a fault, and not something the server can fix: HealthKit’s merged statistics exist only on the phone, so the dashboard, the MQTT sensors and the REST answers are all higher than what Health shows you. The samples table keeps source_device, so you can see which device contributed what.
  • The phone keeps syncing in the background. It does not need you.
  • Work through Deployment — the dashboard is served by the proxy, and the proxy does not start until the certificates exist.
  • Then point a browser at the dashboard from your LAN or VPN.
  • Set up the staleness alert — this system’s characteristic failure is not a crash but silence, and silence is invisible unless something watches for it.
  • Take a backup and restore it once before you trust the setup: Backups and restore.