Build a server for Syncky Health
Syncky Health is an iPhone app. It can send Apple Health data straight from the iPhone to an HTTP server you run: a home server, an automation tool or your own code. This guide describes everything that server sees and everything it may answer. It is written for a person and for an AI agent that builds the server.
For AI agents. Build the receiver from sections 3 to 8. Add section 9 if the server should print a setup link, and sections 10 and 11 if it should write data back into Apple Health. Section 14 has recipes for the app's templates, and section 15 is the final checklist. The vector files in
test-vectors/are the source of truth:webhook-requests.json(real requests in every format with their headers, secrets and setup link),webhook-exchanges.json(replies, the results request and the Check connection request) andwebhook-signatures.json(signature checks). Every example below is copied from those files. When this text and a vector disagree, the vector wins. Test your server by replaying the vectors with their secrets and clock.
The quickest start is the example server in examples/minimal-server. It is one file with no dependencies, it implements every section of this guide, and its tests replay the same vectors.
1. What you are building
The app calls your server a server (in the data, a destination with an id like wh_…). Your server:
- accepts
POSTrequests with a JSON, CSV or InfluxDB line protocol body; - checks the token and, if signing is on, an HMAC signature;
- answers
2xxquickly; - optionally answers with commands that the app writes into Apple Health, and then receives a results request that says what happened.
Nothing passes through Syncky's own servers on this route. One iPhone can have several servers. Each server belongs to one iPhone; there is no shared account.
What that means for responsibility: the server receives the user's health data in clear. HTTPS protects it in transit with TLS; plain HTTP does not encrypt it, on any network. Whoever runs the server is responsible for it under the data protection law that applies to them. Syncky's privacy policy (https://syncky.app/privacy) covers the app and the relay only. Syncky is a data-transfer tool, not a medical device; what a server does with the data is its operator's choice, and nothing in this guide is medical advice.
2. Set up the connection on the iPhone
In the app, open Settings › Connections › +. Then choose one of these:
- Server or automation, or one of the templates under "Start from a template" (Home Assistant, InfluxDB, n8n, Node-RED, Health Auto Export server). A template fills in the format and the URL path. You add the address and the token.
- Paste a setup link. Your server prints a webhook setup link (default
syncky://webhook?…) or a QR code, and the app fills in everything for review and confirmation (section 9).
Then tap Check connection. The app sends one test request with every header, auth and signature included, and no health data. There is no handshake or challenge step: this test is an ordinary request. Setting up and checking is free. Sending data needs a plan: Manual sends when you tap Sync now, Sync also sends automatically and is needed for writes to Health.
The server screen also has these settings:
- Authentication: None, Bearer, Basic or Header. The token, password or header value stays in the iPhone Keychain.
- Sign requests (HMAC): the app generates a secret and shows it once. Copy it to your server.
- Format and Data types.
- Send deletions: on by default.
- Accept writes to Health: off by default. Your server's replies can add records.
3. The request
Every request is a POST to the URL you entered. Here are the headers of a native request with Bearer auth and signing on:
Content-Type: application/json
User-Agent: Syncky/1.0
X-Syncky-Format: native
X-Syncky-Delivery: <a new UUIDv7 for every request>
X-Syncky-Destination: wh_hmf4Hk2wv-D-TUbXkl-UCA
X-Syncky-Timestamp: <unix seconds>
X-Syncky-Signature: v1=<64 lowercase hex digits>
Authorization: Bearer <token>
| Header | Value |
|---|---|
Content-Type |
application/json for native and Health Auto Export, text/csv; charset=utf-8 for CSV, text/plain; charset=utf-8 for InfluxDB |
User-Agent |
Syncky/<app version> |
X-Syncky-Format |
native, hae, csv or influx. Read the body by this header. |
X-Syncky-Delivery |
a new UUIDv7 for every request. Use it as a request id. |
X-Syncky-Destination |
the server's id in the app (wh_…). It names the server entry, not the iPhone. |
X-Syncky-Timestamp, X-Syncky-Signature |
only when signing is on (section 6) |
| auth | Authorization: Bearer <token>, Authorization: Basic <base64(user:password)> or your own header, for example X-Api-Key: <value> |
The results request (section 10) has X-Syncky-Kind: results instead of X-Syncky-Format. Header names are not case-sensitive; many servers see them in lower case.
The Health Auto Export format adds the headers that Health Auto Export servers expect. automation-id is the server id, and session-id is one id for all requests of one delivery:
automation-name: Vector hae-every-shape
automation-id: wh_xG_XgGsxv7V7zu5FlJxMVQ
automation-aggregation: false
automation-period: Since Last Sync
session-id: <one UUIDv7 per delivery>
No header names the iPhone. In the native format the body's device field is the app's install id (d_…). In the other formats, give each iPhone its own server entry or its own URL path.
Rules for your URL:
- It is absolute and starts with
https://orhttp://. It has no user name, no password and no#fragment. A query string is allowed. All syntactically valid HTTP hosts are accepted; URL, port, length and credential validation still applies. - HTTP has no warning for
localhost,*.local,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1,fe80::/10,fc00::/7,100.64.0.0/10and*.ts.net. Every other HTTP host shows: "Health data will travel unencrypted over the internet. Use https:// if your server supports it." HTTPS never shows this warning. An exemption does not mean HTTP encrypts data. - The warning stays under the URL field and on the saved server's detail screen. A new or changed public-HTTP URL needs one explicit confirmation before saving, testing or syncing, including when Check connection or Sync now saves the draft. An unchanged saved URL does not ask again; changing the scheme, port, path or query can require a new confirmation. Setup-link import uses the same warning and confirmation. Cancelling leaves it unsaved and sends nothing.
- The app does not follow redirects. Answer at the URL itself.
- Use a certificate the iPhone trusts. Self-signed and private certificates are not covered by this guide.
Bodies are never compressed.
Deliveries and batching
One sync is one delivery to your server. A large delivery is split into several requests. Each body is at most 1,000,000 bytes. Requests are sent one after another, and the first failure stops the delivery. The data that did not get a 2xx is sent again next time.
Delivery is at least once. You will see repeats, so store samples by key:
- a sample:
(device, uuid); - a part of a workout route or heartbeat series:
(device, uuid, part); - a characteristic (
characteristic:<name>) and anactivity_summaryday: replace the stored value.
4. Body formats
Syncky JSON (native, recommended)
The body is one JSON object:
vis1andtypeis"sync";deviceis the install id;samplesholds the new samples, anddeletedthe deleted ones (section 7);acksis always empty;moreistrueon every request of a delivery except the last;deliveryhasdestination,id(equal toX-Syncky-Delivery),index,countandsent_at, plus"test": trueon the Check connection request.
Times are RFC 3339 in UTC. A quantity value is in the type's unit from types.json. Percent types are fractions from 0 to 1 with the unit %. These samples come from one real request:
{"uuid": "5E1D0000-0000-4000-8000-000000000001", "type": "steps", "value": 412, "unit": "count",
"start": "2026-10-14T07:01:00Z", "end": "2026-10-14T07:01:59Z",
"source": {"app": "com.apple.Health", "device": "Apple Watch"}}
A category sample carries the value's name:
{"uuid": "51EE0000-0000-4000-8000-000000000001", "type": "sleep_analysis", "value": "asleep_core",
"start": "2026-10-13T23:10:00Z", "end": "2026-10-14T00:40:00Z",
"source": {"app": "com.apple.Health", "device": "Apple Watch"}}
A workout:
{"uuid": "0B6F0000-0000-4000-8000-000000000001", "type": "workout", "activity": "running",
"start": "2026-10-14T05:00:00Z", "end": "2026-10-14T05:31:10Z", "duration_s": 1870,
"energy_kcal": 312.4, "distance_m": 5020, "location": "unknown",
"metadata": {}, "events": [], "activities": [], "statistics": [],
"source": {"app": "com.apple.Health", "device": "Apple Watch"}}
A characteristic has a fixed uuid, so a new value replaces the old one:
{"uuid": "characteristic:blood_type", "type": "characteristic", "name": "blood_type", "value": "a_positive"}
The other kinds are state_of_mind, gad7_assessment, phq9_assessment, audiogram, activity_summary, workout_route and heartbeat_series. Routes and heartbeat series can be large, so they come in parts. Each part has part (from 0) and parts. A set is complete when every part is there and the joined points match count:
{"uuid": "0E0B0000-0000-4000-8000-000000000001", "type": "heartbeat_series",
"start": "2026-10-14T06:00:00Z", "end": "2026-10-14T06:01:00Z", "count": 6, "part": 0, "parts": 1,
"beats": [[0, 0], [842, 0], [1677, 0], [2519, 1], [3350, 0], [4188, 0]],
"source": {"app": "com.apple.Health", "device": "Apple Watch"}}
The parts of one set can arrive in different requests. series-parts.json shows how to join them. The schema of the whole body is $defs/sync in schemas/inner-sync.schema.json. The native_* cases of webhook-requests.json hold complete bodies of every kind.
Health Auto Export JSON
This is the body of Health Auto Export's "REST API" automation, so a server written for that app can take it: {"data":{"metrics":[…],"workouts":[…]}}. The arrays stateOfMind and symptoms appear only when they are not empty. Metric names follow Health Auto Export, not the Syncky type names (steps becomes step_count). Dates look like 2026-10-14 09:01:03 +0200. Percent values are multiplied by 100. One metric:
{"name": "heart_rate", "units": "count/min",
"data": [{"date": "2026-10-14 09:01:03 +0200", "Min": 64, "Avg": 64, "Max": 64, "source": "Apple Watch"}]}
One workout:
{"id": "0B6F0000-0000-4000-8000-000000000001", "name": "Running",
"start": "2026-10-14 07:00:00 +0200", "end": "2026-10-14 07:31:10 +0200", "duration": 1870,
"activeEnergyBurned": {"qty": 312.4, "units": "kcal"}, "distance": {"qty": 5.02, "units": "km"}}
This format has no deletions, no characteristics and no delivery object. Types without a Health Auto Export name are not sent. A workout can come again later with its route, so store workouts by id. The hae_* cases of webhook-requests.json show every shape and name. This format has not been checked against a live Health Auto Export receiver.
CSV
RFC 4180 with CRLF line ends. Every request starts with the same header row. A field with a comma, a quote or a line break is quoted. A time is RFC 3339 in the sample's own time zone when the iPhone knows it, else in UTC. These lines come from one real body:
type,uuid,start,end,value,unit,value_name,source,device,user_entered
steps,5E1D0000-0000-4000-8000-000000000001,2026-10-14T09:01:00+02:00,2026-10-14T09:01:59+02:00,412,count,,com.apple.Health,Apple Watch,false
sleep_analysis,51EE0000-0000-4000-8000-000000000001,2026-10-14T01:10:00+02:00,2026-10-14T02:40:00+02:00,3,,asleep_core,com.apple.Health,Apple Watch,false
workout,0B6F0000-0000-4000-8000-000000000001,2026-10-14T07:00:00+02:00,2026-10-14T07:31:10+02:00,1870,s,running,com.apple.Health,Apple Watch,false
characteristic,characteristic:blood_type,,,1,,a_positive,,,
steps,5E1D0000-0000-4000-8000-000000000099,,,,,deleted,,,
- A quantity row has
valueandunit. - A category row has the numeric HealthKit value in
valueand its name invalue_name. - A workout row has the duration in seconds in
value, the unitsand the activity invalue_name. - A characteristic row has no times.
valueis the numeric value andvalue_nameits name; a date characteristic puts the date invalue. - A deletion row has only
type,uuidandvalue_namedeleted. sourceis the app that saved the sample anddevicethe device. When either starts with=,+,-,@, a tab or a CR, it gets a leading', so a spreadsheet does not run it as a formula.user_enteredistrueorfalse.
State of mind, assessments, audiograms, activity summaries, routes and heartbeat series are not sent in CSV.
InfluxDB line protocol
One line per sample, each ending in LF. The measurement is the type name. The tags are unit, source and device, sorted, and empty tags are left out. The timestamp is the sample's start in nanoseconds. These lines come from one real body:
steps,device=Apple\ Watch,source=com.apple.Health,unit=count value=412 1791961260000000000
sleep_analysis,device=Apple\ Watch,source=com.apple.Health value=3,value_name="asleep_core" 1791933000000000000
workout,activity=running,device=Apple\ Watch,source=com.apple.Health duration_s=1870,energy_kcal=312.4,distance_m=5020 1791954000000000000
- A quantity has the field
value. - A category has
value(the numeric HealthKit value) and the string fieldvalue_name. - A workout has the tag
activityand the fieldsduration_sand, when known,energy_kcalanddistance_m. - Spaces, commas and
=in tags are escaped with\. Tabs and line breaks become spaces.
Deletions, characteristics and the other kinds are not sent in line protocol.
The Check connection request
Check connection sends the format's body with no data. Your server should answer it like any request: check the auth and the signature, store nothing, answer 2xx. The app does not read the reply. This is the complete native request:
Content-Type: application/json
User-Agent: Syncky/1.0
X-Syncky-Format: native
X-Syncky-Delivery: 01a1393a-8960-7b26-8ec7-5d0493cc3337
X-Syncky-Destination: wh_hmf4Hk2wv-D-TUbXkl-UCA
X-Syncky-Timestamp: 1791961500
X-Syncky-Signature: v1=b5d4f350a4455a39d6e89cfb3e24d4b96fa4a47b7437b985fd8e8ad26c1baf3c
Authorization: Bearer vec_token_N_2Y13AA1iajwMKegGliR7uA-JPcykkrnx_oquTiDU0
{"v":1,"type":"sync","device":"d_zsOZnTQUz_juWFVljuOOBQ","samples":[],"deleted":[],"acks":[],"more":false,"delivery":{"destination":"wh_hmf4Hk2wv-D-TUbXkl-UCA","id":"01a1393a-8960-7b26-8ec7-5d0493cc3337","index":0,"count":1,"sent_at":"2026-10-14T07:05:00Z","test":true}}
Health Auto Export:
{"data":{"metrics":[],"workouts":[]}}
CSV, the header row alone (one line ending in CRLF):
type,uuid,start,end,value,unit,value_name,source,device,user_entered
InfluxDB line protocol, an empty body:
5. Data types and units
Type names are short snake-case names, for example steps, heart_rate, dietary_water and body_mass. They match ^[a-z][a-z0-9_]{1,63}$. The HealthKit identifier of each type is in types.json as hk.
types.jsonlists every type: its kind, its HealthKit identifier, its unit, whether it can be written, its bounds and its category values. It also lists the workout activities and the characteristics.units.jsonlists the unit families and how to convert between units.healthkit-inventory.mddescribes every HealthKit data kind in plain words.
The lists grow with new app versions. Do not hard-code them, and accept type names you do not know yet.
6. Authentication and signatures
The app supports four kinds of auth: none, Bearer, Basic and a custom header. Compare the token in constant time.
Turn on Sign requests (HMAC) for any server that can be reached from the internet. The app generates the secret: whsec_ followed by 43 base64url characters. You can also put your own secret in the setup link (section 9).
Check every signed request like this:
- Take the raw body bytes, before any JSON parsing.
- Compute HMAC-SHA256 with the key = the whole
whsec_…string as UTF-8, overX-Syncky-Timestamp+.+ the raw body. - Write it as
v1=and 64 lowercase hex digits. - Compare it with the whole
X-Syncky-Signaturevalue in constant time. - Accept only if the timestamp is within 300 seconds of your clock.
Uppercase hex, a v0= prefix, a missing v1= and a body changed by one byte must all fail. There is no nonce, and X-Syncky-Delivery is not covered by the signature. If you need replay protection inside the 300-second window, keep the X-Syncky-Signature values of the last 300 seconds and reject a repeat: every attempt, retries included, is signed again with a new timestamp.
A worked example: the Check connection request in section 4 was signed with the secret whsec_mgHtkTuuLB_wsJa221vi031K8GmN9QbtPNq_RV8uD8c. Your check must accept it with the clock at 1791961500 and reject it when one byte changes.
The same check in Node, with the reply signer of section 10:
import { createHmac, timingSafeEqual } from "node:crypto";
const hmacHex = (secret, prefix, body) => createHmac("sha256", Buffer.from(secret, "utf8")).update(prefix).update(body).digest("hex");
export function verifyRequest(headers, rawBody, secret, now = Math.floor(Date.now() / 1000)) {
const timestamp = headers["x-syncky-timestamp"];
const signature = headers["x-syncky-signature"];
if (typeof timestamp !== "string" || !/^[0-9]{1,15}$/.test(timestamp) || typeof signature !== "string") return false;
const expected = Buffer.from(`v1=${hmacHex(secret, `${timestamp}.`, rawBody)}`, "utf8");
const given = Buffer.from(signature, "utf8");
return given.length === expected.length && timingSafeEqual(given, expected) && Math.abs(now - Number(timestamp)) <= 300;
}
export function signReply(secret, delivery, t, replyBody) {
return `t=${t},v1=${hmacHex(secret, `${delivery}.${t}.`, replyBody)}`;
}
rawBody is a Buffer with the exact bytes you received. In Express, for example, use express.raw({ type: "*/*" }) instead of express.json() on this route. webhook-signatures.json has a case for every rule above.
7. Deletions
When a sample is deleted in Apple Health, the app tells your server, unless Send deletions is off.
- Native: an entry in
deletedwith the sample'suuidandtype. Remove what you stored under(device, uuid), all parts included. - CSV: a row with
value_namedeleted. - Health Auto Export and InfluxDB line protocol: deletions are not sent.
{"uuid": "5E1D0000-0000-4000-8000-000000000099", "type": "steps"}
A deletion can name a sample you never stored, for example one deleted before it was sent. Ignore it.
8. Responses, retries and limits
| Your answer | Code | What the app does |
|---|---|---|
2xx |
none | The request counts as delivered. The body is read only for write-back (section 10). |
3xx |
redirect |
Failure. Redirects are never followed. |
401 or 403 |
auth_failed |
Failure. |
413 |
payload_too_large |
Failure. The app halves the body limit for this server, down to 65,536 bytes, and the next attempt uses it. |
429 |
rate_limited |
Failure. |
any other 4xx |
http_4xx |
Failure. |
5xx |
http_5xx |
Failure. |
| no answer in time | timeout |
Failure. |
| no connection (DNS, TCP, TLS) | network |
Failure. |
After a failure the app waits before it tries again. Sync now ignores the wait. A success resets it. The app shows the server as Retrying with the last error. Answer 2xx as soon as the body is stored, and do slow work later.
| Limit | Value |
|---|---|
| Servers per iPhone | 50 |
| Server name | 64 characters |
| URL | 2048 characters |
| Token, password or header value | 4096 bytes |
| Custom header name | 256 characters |
| Basic user | 256 characters, printable ASCII, no colon |
| Request body | 1,000,000 bytes at first; each 413 halves it, down to 65,536 bytes |
| Request timeout | 30 s |
| Reply the app reads | 65,536 bytes; a longer reply is ignored |
| Signature window | 300 s each way, for requests and for replies |
| Backoff after a failure | 30 s, 2 min, 10 min, 1 h, then every hour |
A custom header cannot be Host, Content-Type, Content-Length, User-Agent, Authorization, Transfer-Encoding, Upgrade, Connection, Keep-Alive, TE, Trailer or any name starting with X-Syncky-.
9. Setup links
A setup link fills in the whole server entry. Your server prints it, and the user pastes it or scans it as a QR code in Settings › Connections › + › Paste a setup link.
The supported schemes are exactly syncky (Syncky), syncky-dev (Syncky Dev) and syncky-staging (Syncky β). Link builders default to syncky. In the app, paste and scan accept webhook setup links with all three schemes. For pair links to relay agents, the app refuses a link for another build. A tapped webhook setup link opens the build registered for its own scheme and shows the import review and confirmation; no server is added before you confirm.
syncky://webhook?v=1&name=<pct>&url=<pct>&format=native|hae|csv|influx&auth=none|bearer|header|basic
[&header=<name>][&user=<pct>][&token=<pct>][&hmac=<whsec_…>][&types=<a,b>]
<pct>: percent-encode every byte outsideA-Z a-z 0-9 - . _ ~of the UTF-8 text as%XX.tokenis the Bearer token, the header value or the Basic password. It is present exactly whenauthis notnone.headeronly withauth=header,useronly withauth=basic.hmacturns signing on with that secret, so your server can make the secret and the app adopts it.typeslimits the data types, as a comma-separated list of type names.- Unknown or repeated parameters, a fragment or a
vother than1make the link invalid. - A link cannot turn on Accept writes to Health or change Send deletions. The user sets those in the app.
A link for a custom header and two types:
syncky://webhook?v=1&name=Vector%20header&url=https%3A%2F%2Freceiver.example%2Fhook%2Fheader&format=native&auth=header&header=X-Api-Key&token=vec_key_x9VHBdwKQo-KwlcOB9v4cwlAawCa0nF2NWQRO1qLDlI&types=heart_rate,steps
Every case of webhook-requests.json carries the link for its server, so you can test your encoder against them. Optional case-level url_scheme supplies the link builder's scheme; absent means syncky. It does not change request bodies or headers.
The link carries secrets and is not signed. Show it only to the iPhone's owner, best as a QR code on a local screen. The app shows the name, the host and the format for explicit confirmation before it imports. After the import it runs Check connection by itself.
To print a QR code in a terminal, pipe the link into a QR tool, for example with the example server:
./server.mjs link --host myserver.local | qrencode -t ansiutf8
10. Write-back: commands and results
Your server can add records to Apple Health. The app reads a reply only when:
- Accept writes to Health is on for the server, and the iPhone has the Sync plan;
- the reply answers a data request with
2xx. Replies to Check connection and to the results request are not read; - the reply is at most 65,536 bytes, has
Content-Type: application/jsonand is exactly a commands message.
Commands reach the iPhone only in the reply to a data request. When the iPhone has nothing new to send, it makes no data request, and your commands wait for the next one. An empty body means no commands. A reply the app cannot use is ignored; the delivery still counts as delivered, and the server's status keeps the reason (reply_ignored: and too_large, not_json, not_commands, unsigned or bad_signature).
Over plain HTTP the reply must be signed, and signing must be on for the server. The reply signature differs from the request signature: it has its own time and covers the request's delivery id.
X-Syncky-Signature: t=<unix seconds>,v1=<64 lowercase hex digits of HMAC-SHA256(secret, X-Syncky-Delivery + "." + t + "." + raw reply body)>
The time must be within 300 seconds of the iPhone's clock. Over HTTPS the reply signature is not checked.
An example. The app sent a native request with these headers:
X-Syncky-Delivery: 01a13940-fe30-7630-9d7f-9da6e6de1e3d
X-Syncky-Timestamp: 1791961923
The server is at http://receiver.local/hook/every-kind, with the secret whsec_mgHtkTuuLB_wsJa221vi031K8GmN9QbtPNq_RV8uD8c. It answers with two writes:
Content-Type: application/json
X-Syncky-Signature: t=1791961923,v1=73d9d986299402060d48ffe640a17e657067e1a9e853ccdd3136c5e74a5163f5
{"v":1,"type":"commands","commands":[{"command_id":"c_1","version":1,"op":"write","type":"dietary_water","value":0.25,"unit":"L","start":"2026-10-14T07:00:00Z","end":"2026-10-14T07:00:00Z"},{"command_id":"c_2","version":1,"op":"write","type":"apple_exercise_time","value":30,"unit":"min","start":"2026-10-14T07:00:00Z","end":"2026-10-14T07:00:00Z"}]}
The same two commands, one per line for reading:
{"command_id": "c_1", "version": 1, "op": "write", "type": "dietary_water", "value": 0.25, "unit": "L",
"start": "2026-10-14T07:00:00Z", "end": "2026-10-14T07:00:00Z"}
{"command_id": "c_2", "version": 1, "op": "write", "type": "apple_exercise_time", "value": 30, "unit": "min",
"start": "2026-10-14T07:00:00Z", "end": "2026-10-14T07:00:00Z"}
Rules for commands (schema: schemas/inner-commands.schema.json):
command_idmatches^[A-Za-z0-9_.:-]{1,64}$, andversionstarts at 1.- A quantity write has a number
valueand aunitfrom the type's unit family inunits.json. A category write has the value's name and no unit. - Only quantity and category types with
"write": trueintypes.jsoncan be written, and only within the type's bounds. The user must also allow the type in the Health permission sheet. - The same
command_idwith a higherversionreplaces the earlier command. The same or a lower version is never applied twice; when the command already has a result, the app sends that result again. op: deletewith thecommand_id, a higherversionand thetyperemoves what that command wrote.- The app writes each record with the HealthKit sync identifier
syncky:wh_<id>:<command_id>.
apple_exercise_time cannot be written, so the app rejects c_2 at once and writes c_1.
Results
After it handles your commands, the app posts a results request to the same URL. It has X-Syncky-Kind: results and no X-Syncky-Format:
Content-Type: application/json
User-Agent: Syncky/1.0
X-Syncky-Kind: results
X-Syncky-Delivery: 01a13941-7360-7ee0-91c5-e3ae2be0fb4e
X-Syncky-Destination: wh_hmf4Hk2wv-D-TUbXkl-UCA
X-Syncky-Timestamp: 1791961953
X-Syncky-Signature: v1=faf090fb6106803f7435919ab1383d8b52256a0d4b86e99d22b110a9d6e32132
Authorization: Bearer vec_token_N_2Y13AA1iajwMKegGliR7uA-JPcykkrnx_oquTiDU0
{"v":1,"type":"results","results":[{"command_id":"c_1","version":1,"status":"applied"},{"command_id":"c_2","version":1,"status":"rejected","reason":"type_not_allowed"}]}
statusisappliedorrejected. A rejection has areason:type_not_allowed,value_out_of_range,healthkit_deniedorinvalid.- Check the auth and the signature as for any request, and answer
2xx. A failure is retried on the backoff schedule of section 8. - The results of one reply can come in one request or in several. Rejections the app decides at once come right after the delivery; writes come after Health has stored them.
- A command is done when a result for its latest version arrives. Keep sending the command until then; the app applies each version only once.
- Servers are not told when the user later deletes a record in Health.
Deleting a written record
To remove the water entry, answer a later data request with a delete. This reply answers a data request whose X-Syncky-Delivery was 01a13977-ecb0-7fbd-bd2a-03d2ebe40d71:
Content-Type: application/json
X-Syncky-Signature: t=1791965523,v1=42e6daf6d840b6ffbbb1b9e81c2f94c525743082b26f4eca1a7cec1034e68c40
{"v":1,"type":"commands","commands":[{"command_id":"c_1","version":2,"op":"delete","type":"dietary_water"}]}
Its result is c_1 with version 2.
Your own writes come back to you as new samples in later deliveries, like any other Health data. There is no filter for them. This has not been checked end to end.
11. Write-back walkthrough with the example server
This uses examples/minimal-server on a computer on the same Wi-Fi as the iPhone. It needs Node 22.15 or later.
Create the token and the signing secret, start the server, and in a second terminal print the link as a QR code:
./server.mjs init ./server.mjs serve --bind 0.0.0.0 ./server.mjs link | qrencode -t ansiutf8linkuses this computer's name with.local. Pass--host <name>.localto change it.On the iPhone: Settings › Connections › + › Paste a setup link, scan the code and confirm. The app runs Check connection. The server logs a
testrequest.Open the new server and turn on Accept writes to Health.
Queue the two writes of section 10:
./server.mjs write dietary_water 0.25 L --at 2026-10-14T07:00:00Z ./server.mjs write apple_exercise_time 30 min --at 2026-10-14T07:00:00ZTap Sync now. The first delivery sends the history window, and the server answers it with the reply of section 10, signed over that request's delivery id. If the history was already sent, add new data first (see step 7).
The app rejects
c_2and writesc_1, then sends the results../server.mjs statusnow shows two results and no command waiting. The water entry is in the Health app.Run
./server.mjs delete c_1and send new data, for example by logging a glass of water in the Health app. The next reply carries the delete of section 10, and the entry is gone.
The server keeps every command until a result for its latest version arrives, so a reply lost on the way is sent again.
12. When data arrives
The app sends:
- when Health reports new data, collected for 2 seconds;
- on Background App Refresh;
- from a Shortcuts automation or a widget;
- when the app opens, at most once a minute;
- when the user taps Sync now.
Everything except Sync now needs the Sync plan. iOS decides when a locked iPhone wakes up, so expect bursts, not a fixed schedule. The first delivery to a new server sends the history window, the last 30 days by default.
13. Local network and remote access
A server at home can use plain HTTP on a local address. HTTPS is recommended for remote access; HTTP remains available for any valid host under the warning and confirmation rules in section 3.
- Names ending in
.localare found over Bonjour on the same Wi-Fi. - Listen on the computer's network address, not only on
127.0.0.1, and allow the port in its firewall. - The app checks local-network access only for LAN addresses: RFC 1918, IPv4/IPv6 link-local,
*.localand ULA outside the Tailscale range. This applies to LAN HTTPS too. The first enabled save/import and explicit Check connection or Sync now can prompt. The iOS prompt says: "Syncky sends Health data to servers on your local network that you add." - Loopback, overlay and public hosts skip the probe. Overlay means CGNAT
100.64.0.0/10,*.ts.netand Tailscale IPv6fd7a:115c:a1e0::/48. Other ULA addresses use the LAN probe. IPv4-mapped IPv6 follows its embedded IPv4 address. Classification uses the address text without a DNS lookup; a normal domain resolving to a private address is still classified as public. - If the user said no, the app shows: "Local network access is off. Allow Syncky in Settings › Privacy & Security › Local Network." This denial stops the action and is not reported as a timeout. Check again after changing Settings. An unreachable LAN check leaves a saved server saved; Check connection stops, while Sync now proceeds to normal delivery diagnostics. Background runs do not probe and cannot distinguish a permission denial from a transport timeout.
- Away from that Wi-Fi, a LAN address needs a working route such as a VPN. Deliveries fail with
networkwhen unreachable and retry later. No data is lost: the app sends it again when the server answers. For Home Assistant, use the recommended Tailscale Serve setup below with the iPhone connected to the tailnet. - On every plain-HTTP write-back path, including VPN hosts, the app requires signed replies (section 10).
14. Recipes
Each template in the app links to its recipe here. The details of the products below come from their documentation and are not verified by Syncky.
Home Assistant
Template: format native, URL http://homeassistant.local:8123/api/webhook/syncky-health, auth None.
Add this automation. It logs the number of samples and skips the Check connection request:
alias: Syncky Health
triggers:
- trigger: webhook
webhook_id: syncky-health
allowed_methods: [POST]
local_only: true
conditions:
- condition: template
value_template: "{{ not (trigger.json.delivery.test | default(false)) }}"
actions:
- action: logbook.log
data:
name: Syncky Health
message: "{{ trigger.json.samples | length }} samples, {{ trigger.json.deleted | length }} deletions"
trigger.jsonis the parsed body (section 4). Each sample hastype,valueandunit.The webhook id is the only secret. A webhook trigger cannot check a token or the HMAC signature. Replace
syncky-healthwith a long unpredictable webhook id in Home Assistant and the app URL. Do not publicly expose an unauthenticated Home Assistant webhook.For remote access, the recommended setup is Tailscale Serve on the Home Assistant host. Enable HTTPS certificates in the tailnet's DNS settings, then run:
tailscale serve --bg 8123Keep the iPhone connected to the tailnet and enter
https://<host>.<tailnet>.ts.net/api/webhook/<id>in Syncky. Home Assistant sees the local proxy, so keep the defaultlocal_only(true); the explicitlocal_only: trueabove is also suitable.The plain-HTTP alternative is direct access to a tailnet address in
100.64.0.0/10, for examplehttp://100.119.70.126:8123/api/webhook/<id>; only this direct-tailnet alternative needslocal_only: false. Home Assistant does not count this direct tailnet request as local. Keep the iPhone on the tailnet and restrict access there. The absence of an HTTP warning for this address does not make HTTP itself encrypted.Home Assistant answers a webhook without a body, so this recipe does not write back.
Not verified by Syncky: the trigger options and the reply of Home Assistant.
InfluxDB
Template: format influx, URL http://influxdb.local:8086/api/v2/write?org=home&bucket=health&precision=ns, auth Bearer.
- In InfluxDB 2, create the bucket
healthin the organizationhome, or changeorgandbucketin the URL. - Create an API token that can write to the bucket, and enter it as the token in the app.
- Keep
precision=ns: the timestamps are in nanoseconds.
- InfluxDB documents
Authorization: Token <token>. The app sendsAuthorization: Bearer <token>, andAuthorizationcannot be set as a custom header. Whether your InfluxDB acceptsBeareris not verified by Syncky. If it answers401, put a small proxy in front that rewrites the header. - InfluxDB does not check
X-Syncky-Signature, so signing adds nothing here. - Check connection sends an empty body. How InfluxDB answers it is not verified by Syncky.
- Deletions, characteristics and the other kinds are not sent (section 4).
n8n
Template: format native, URL https://n8n.example.net/webhook/syncky-health, auth Header X-Api-Key.
- Add a Webhook node: HTTP method
POST, pathsyncky-health. - Set Authentication to Header Auth. Create a credential with the name
X-Api-Keyand a long random value. Enter the same value in the app. - Use the production URL (
/webhook/…). The test URL (/webhook-test/…) works only while the editor listens. - Read the body from the node's output:
body.samples,body.deletedandbody.delivery.test.
For write-back, set the Webhook node to respond with a Respond to Webhook node. Respond with JSON: a commands message (section 10) with Content-Type: application/json. Over HTTPS the reply needs no signature. If the node answers with its default message instead, the app ignores it.
Not verified by Syncky: the n8n node names and options, and how n8n keeps the raw body for an HMAC check.
Node-RED
Template: format native, URL http://nodered.local:1880/syncky-health, auth None.
Build the flow http in (method POST, URL /syncky-health) → function → http response. Give the function two outputs:
const sync = msg.payload;
const samples = sync.delivery && sync.delivery.test ? [] : sync.samples;
return [{ ...msg, statusCode: 200, payload: "" }, samples.length > 0 ? { payload: samples, device: sync.device } : null];
The first output answers the iPhone with an empty body. The second carries the samples to the rest of your flow.
- The http in node parses the JSON and does not keep the raw bytes by default. An HMAC check needs the raw bytes (section 6), so leave signing off on a trusted home network.
- Over plain HTTP, write-back needs signing on and a signed reply. The function can sign with Node's
cryptomodule if your Node-RED allows modules in function nodes.
Not verified by Syncky: the Node-RED node options and the raw body.
Health Auto Export server
Template: format hae, URL http://hae.local:3001/api/data, auth Bearer.
Use this for a server built for Health Auto Export's "REST API" automation, for example HealthyApps' own server.
- The body and the
automation-*andsession-idheaders follow that app (sections 3 and 4). - If your receiver expects its key in another header, set Authentication to Header and enter that header's name.
- There are no deletions in this format.
- A workout can come again with its route later; your receiver should replace workouts by
id.
Not verified by Syncky: this format against a live Health Auto Export receiver, and the receiver's auth.
15. Receiver checklist
- Accept
POST, readX-Syncky-Formatand parse the body by it. - Check the token in constant time. Check the HMAC over the raw body in constant time, within 300 s.
- Answer the Check connection request with
2xxand store nothing. - Store samples by
(device, uuid)and parts by(device, uuid, part). Replace characteristics and activity summaries. Apply deletions. - Answer
2xxwithin a few seconds. Never redirect. Answer413only when the body is really too large. - Optional: reply with commands (JSON, signed over plain HTTP), accept the results request, and keep each command until its result arrives.
- Optional: print a webhook setup link or a QR code with your URL, token and
hmacsecret; default tosyncky, or selectsyncky-dev/syncky-staging(section 9). - Replay
webhook-requests.json,webhook-signatures.jsonandwebhook-exchanges.jsonagainst your server.