Publishing Personal Data on Your Site: The Signed Endpoint Pattern

Apple Health has no API. Spotify won't let you read without OAuth, and your other projects can't just write to your site either. One pattern solves all three: a signed endpoint your devices push to.

Published on August 22, 2026


Views100 views
Reading time:8 min

I wanted to show my daily step count on my site. I assumed it would be a matter of reading Apple's Health API docs, grabbing a token, and moving on.

That API doesn't exist. Finding out why led me to a pattern I now use for four different things on this same site.

The wrong mental model

Apple Health has no server-side API. None. The data lives on the iPhone, iCloud syncs it end-to-end encrypted, and there is no endpoint your backend can call to ask "how many steps did this user take today?" HealthKit is an on-device framework: only an app installed on the phone, with explicit permission, can read it.

That inverts the whole architecture. Your site doesn't read from your phone:

iPhone ──(pushes)──► your endpoint ──► your database ──► your site

And once you draw it that way, you realize you've solved this before. It's the same shape as a GitHub webhook, or a side project sending events to your main site. The device, or the project, or the service pushes. You receive, validate, and publish.

I call it the signed endpoint pattern, and it has three pieces.

Piece 1: an endpoint that trusts nobody

If something external is going to write to your site, that endpoint is attack surface. At minimum it needs:

Shared-secret auth, compared in constant time. A plain === on strings leaks information through how long it takes to fail:

import { timingSafeEqual } from "node:crypto"

export const safeCompare = (left: string | null, right: string): boolean => {
  if (!left) return false
  const a = Buffer.from(left)
  const b = Buffer.from(right)
  if (a.length !== b.length) return false
  return timingSafeEqual(a, b)
}

Strict validation with bounds. Not just "is a number" — a plausible number. Nobody walks four million steps or has a resting heart rate of 900:

const bodySchema = z.object({
  steps: metric(0, 200_000),
  restingHeartRate: metric(25, 220),
}).strict()

That .strict() matters: it rejects fields you never declared, so nobody fills your database with arbitrary junk.

A body size cap, and rate limiting if the endpoint is public. Mine is private (only my phone calls it), so the token is enough.

Piece 2: the device that pushes

This is where Apple forces you to get creative. The real options for getting data out of Health:

RouteCostReliability
ShortcutsFreeGood, with caveats
Your own HealthKit app$99/year developer accountHighest
Apps like Health Auto ExportOne-off or subscriptionGood
Wearable with an API (Oura, Whoop, Garmin, Strava)VariesHighest: server to server
Exporting Health's XMLFreeA static snapshot only, no live data

I picked Shortcuts: no code, no cost, and a daily automation that reads Health and does the POST. If you own a wearable with its own API, that path is better — it's server to server and doesn't depend on your phone being awake.

The Shortcuts minefield

This is where I burned time, so here are the tripwires:

"Find Health Samples" doesn't give you a number, it gives you a list. Steps are hundreds of samples across the day. You need a "Calculate Statistics → Sum" action afterwards. Send the list directly and your endpoint receives anything but an integer.

For resting heart rate, the sample isn't the value. Add "Get Details of Health Sample → Value" to get the bare number. Without it you get text with units attached.

Sort and limit. "Limit 1" without sorting hands you an arbitrary sample from the window. Sort by start date, descending, take one.

Widen the time window for anything that isn't daily. Steps are from today, but resting heart rate is computed by the watch once a day, sometimes late. I query the last 7 days and take the most recent.

Data type names show up in English even with the system in another language.

Automations don't sync across devices. You can create the shortcut on the Mac (it syncs via iCloud), but the "every day at 11:45pm" trigger can only be created on the iPhone. And note: Health actions don't exist in Shortcuts on macOS, because the Mac has no health database. I checked the system framework for the action identifiers and there isn't a single one.

Piece 3: tolerance, because the real world is messy

This is the detail that separates a toy from something that survives months untouched.

Shortcuts serializes values as strings or decimals depending on the action. And when a sample doesn't exist yet, it doesn't omit the field: it sends an empty string. My first version validated strict integers, so:

  • "8123" → rejected.
  • 8123.7 → rejected.
  • "" → coerced to 0 → out of bounds → the entire request failed, losing the step count too, just because the heart rate wasn't there.

That last case is the worst, because it's silent and only happens on some days. The version that holds up:

const metric = (min: number, max: number) =>
  z.union([z.number(), z.string(), z.null()])
    .optional()
    .transform((value) => {
      // Empty means "no sample today", not zero.
      if (value === undefined || value === null || value === "") return
      return Math.round(Number(value))
    })
    .refine((value) =>
      value === undefined ||
      (Number.isFinite(value) && value >= min && value <= max)
    )

And when storing, merge instead of overwrite: a steps-only ping shouldn't wipe yesterday's heart rate.

The other decision you'll thank yourself for: the widget hides itself when the data is more than 36 hours old. An automation will fail sooner or later — Shortcuts automations reading Health can fail while the phone is locked — and showing nothing beats showing last Tuesday's steps as if they were today's.

Leaving room for more metrics

If the endpoint has metric names baked into its code, adding "hours of sleep" touches four files. A small registry turns that into one line:

export const HEALTH_METRICS = {
  restingHeartRate: { max: 220, min: 25 },
  steps: { max: 200_000, min: 0 },
  // sleepHours: { max: 24, min: 0 },
} as const

The validation schema is generated from it, and the UI iterates over whichever metrics are present. Adding one is: a registry entry, its label in the translation files, and reading it in the shortcut into a field with that same name.

The same thing, four times

Once the pattern is in place, it shows up everywhere. This site now runs four instances of the same shape:

  • Visits from my side projects. Six projects (SmoothUI, SparkBites, TheGridCN, UI Craft, Codevator and Mallard's docs) sign with HMAC on their server and send each visit to the activity feed here. Never from the browser: the secret would leak on the first view source.
  • GitHub PRs and stars, via webhook with verified signature.
  • The health data from this article.
  • AMA questions, which land in a Notion database and get published when I answer them.

Different sources, different transports, same shape: something external pushes, I validate with a secret and bounds, store in Redis, and publish only the coarse version.

What I wouldn't publish

A word of caution, because it's easy to get carried away: this ends up on a public site, indexed and archived.

I publish steps and resting heart rate. I don't publish weight, heart rate variability, or anything resembling a medical record. Visit geolocation stays at city level with no IPs — I don't even store them to compute it, I use the headers the CDN already provides. And in the AMA, a question doesn't appear in the feed until it's answered.

The rule I apply: if a stranger holding that data can do something with it, it doesn't ship. If it only tells them I'm alive and walk a fair bit, it ships.

Worth it

The widget says "Today — 7,949 steps, 70 bpm resting" in a corner, and almost nobody will notice.

But the road there left me three things: understanding why HealthKit is on-device and not an Apple oversight, a pattern I've already reused for six projects pushing into one place, and the habit of asking, before publishing any personal data, whether I'd want it in a stranger's hands.

For a personal site — a place where you show who you are — that last one is the part that matters.