All documentation

Destinations

Your own endpoint

POST any of five delivery formats.

Format
Any of the five
Method
POST
Accepts
Any 2xx

Point Hozz at any endpoint that accepts a POST.

Mac, Home Assistant and InfluxDB use the same delivery mechanism with preset fields.

Setting it up#

  1. Put the full URL in the address field#

    Query string included, if you need one.

  2. Add an authorization header if your endpoint wants one#

    The secret field holds the entire header value. The header name defaults to Authorization. It is stored in the device Keychain, not in the destination record.

  3. Choose a format#

    All five are available. NDJSON is the default.

  4. Send a test#

    Sends a probe without Health data and reports the response.

What a request looks like#

A delivery
POST /health HTTP/1.1
Host: example.com
Content-Type: application/x-ndjson
Idempotency-Key: 2f1a9c04-8e2b-4a1d-9f30-11c4e6a7b920
Hozz-Batch-Id: 2f1a9c04-8e2b-4a1d-9f30-11c4e6a7b920
Hozz-Batch-Sequence: 41
Hozz-Record-Count: 500
X-Hozz-Device: Brandon's iPhone
Authorization: <whatever you configured>

{"catalogVersion":6,"id":"…","kind":"quantity","…":"…"}
{"id":"9c40…","kind":"deletion","schemaVersion":1,"type":"HKQuantityTypeIdentifierStepCount"}
Headers on every delivery
HeaderMeaning
Content-TypeThe format’s media type.
Idempotency-KeyThe batch identifier. A repeat means the same bytes.
Hozz-Batch-IdThe same value, under a Hozz-specific name.
Hozz-Batch-SequenceBatch number for this destination.
Hozz-Record-CountHow many records the payload holds.
X-Hozz-DeviceWhat this phone calls itself.

How to answer#

Return 2xx to accept. Hozz retries 408, 429 and 5xx; other responses stop for attention.

Idempotency#

The id derives from payload bytes. Identical retries reuse it; changed payloads get a new id.

A receiver, in full#

A minimal receiver
from http.server import BaseHTTPRequestHandler, HTTPServer

seen = set()

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        key = self.headers.get("Idempotency-Key")
        body = self.rfile.read(int(self.headers["Content-Length"]))

        # A repeated key means byte-identical content, so it is safe to drop.
        if key not in seen:
            seen.add(key)
            for line in body.splitlines():
                store(line)          # your problem, not Hozz's

        self.send_response(200)      # any 2xx is an acceptance
        self.end_headers()

HTTPServer(("", 8000), Handler).serve_forever()

A dependency-free example lives in receiver/.

Choosing a format#

Delivery formats available to a web destination
FormatMedia typeNotes
NDJSONapplication/x-ndjsonOne record per line. Lossless and streamable.
JSONapplication/jsonThe same records as one array.
CSVtext/csvFlat table. Drops metadata, devices, workout detail and route points.
Metrics JSONapplication/jsonGrouped by metric. Drops metadata, devices and workout detail.
InfluxDB line protocoltext/plain; charset=utf-8For InfluxDB and Telegraf. Drops metadata, workout detail and most device detail.