Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live service sends a samplePk and grams. A weigh-in now takes its id from samplePk or else its own GMT instant, reads each mass by magnitude (above 1000 is grams), and is written whole or not at all: a missing weight, a missing instant or one implausible value drops every row of it, counted on the wide event by reason. Refused resources: a lasting 4xx on one resource (a cycle summary for an account that tracks no cycle) is noted once on the progress column, counts as done for the backfill, is skipped by the hourly sync and is listed in the card's details. It no longer holds the backfill open or turns the connection red. The cycle leg never parks the connection, whatever it is refused with. Backfill: weigh-ins walk back to the start of Garmin Connect on their own, so history before the first watch day and a scale-only account arrive. Progress and the completion stamp are written only while the connection the slice started on is still the stored one. Key: connect sends the key to the one service the person picks (Garmin Connect or Garmin Connect China), never to both. A parked key is asked about again once a day with a single token exchange and reconnected when Garmin accepts it. The key's end is read from the token's own iat and exp, an estimate the key outlived is cleared after an accepted exchange, and the validity can be corrected from the card's menu (PATCH /api/garmin/key-expiry, with the day the key was created) without pasting it again. Sync: an incremental run reaches back to the last successful sync, up to a year, so an outage longer than the 30-day window leaves no gap; a night Garmin no longer reports (deleted, or turned into a nap) is retired. Card: connect errors are translated from meta.errorCode in every locale. Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and ACUTE_TRAINING_LOAD get source ladders of their own (the device or the bridge first, then the hand-entered sources in their usual order), join the ranked types and the provenance picker, and the recovery-empty description names them.
HealthLog API Spec
openapi.yaml is the OpenAPI 3.1 description for the HealthLog
Next.js API and the source of truth for the native iOS client's DTO
codegen. The file is generated: pnpm openapi:generate emits it
from the Zod registry under src/lib/openapi/ (registry.ts plus the
route modules in src/lib/openapi/routes/). Do not edit the YAML by
hand; the next generation overwrites it, and CI (openapi:check)
fails the build when the committed YAML drifts from the registry.
The MCP server is documented separately — it is a distinct contract over JSON-RPC, not part of the REST OpenAPI spec:
mcp-capabilities.md— the tool, resource, and prompt catalogue, the grounding contract, and the write model.mcp-skills.md— building a connector or skill on top of it.../self-hosting/mcp.md— enabling and connecting the surface (operator guide).
Preview locally
Open an interactive Redoc preview in your browser:
npx @redocly/cli preview docs/api/openapi.yaml
Generate static HTML for sharing:
npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html
Validate
npx @redocly/cli lint docs/api/openapi.yaml
The spec is expected to be lint-clean (errors = 0). Warnings such as
no-server-example.com for the localhost dev server are tolerated;
re-run the lint to see what it produces against the current file
before treating a warning as a regression.
Layout
info— title, description, andversionmirrored automatically frompackage.jsonat generation time. Never bump it by hand.servers—https://healthlog.example.comas the self-hosted placeholder plushttp://localhost:3000for local development.tags— domain groupings; the current set lives at the top of the generated file (Auth,Measurements,Medications,Mood,Analytics,Insights,Consent,Dashboard,Notifications,Devices,Export,Sync,Cycle,MeasurementReminders,Labs,Illness,Documents,Records,Admin,Meta,Retired).securitySchemes:-
bearerAuth—hlk_*API tokens. A scope grants what it names and nothing adjacent, so where a token comes from decides what it reaches:- a completed password / passkey / refresh exchange mints the
cookie-equivalent
["*"]token the native client carries; - a medication's own API-endpoint toggle mints
medication:ingestplusmedication:<id>:ingest, the pair/api/ingest/medicationgates on; POST /api/mcp/tokensmintshealth:read(optionally plushealth:write), audience-bound to/mcpand refused on REST writes;POST /api/tokens/measurementsmintsmeasurements:write, accepted byPOST /api/measurementsandPOST /api/measurements/batchonly, on the holder's own record only. The readings it writes carrysource: EXTERNALand are linked to the token; whether the HealthLog app writes them into Apple Health is the token'smirrorToAppleHealthsetting (off by default, changed withPATCH /api/tokens/{id}), resolved onto every measurement row asappleHealthMirror;POST /api/tokens/workoutsmintsworkouts:write, accepted byPOST /api/workouts/batchonly, on the holder's own record only; the workouts it writes carrysource: EXTERNAL;POST /api/tokens/documentsmintsdocuments:write, accepted byPOST /api/documents/inboundandGET /api/documents/inbound/sourceonly.
POST /api/tokensis not among them — that generic mint was removed in v1.30.17. The path still lists (GET) and revokes (DELETE /api/tokens/{id}) every token above, whichever surface issued it. - a completed password / passkey / refresh exchange mints the
cookie-equivalent
-
sessionCookie— HttpOnly session cookie set by the password and passkey login flows. Used by the web app and any iOS client running inside a web view. It is named__Host-healthlog_sessionwherever it isSecure(every HTTPS install), so no other host under the same domain can set it; a plain-HTTP install keepshealthlog_session. A cookie under the old name is still read and is re-issued under the new one.
-
components.schemas— DTOs for the documented routes plus the underlying Prisma models (e.g.Measurement,Medication,MedicationIntakeEvent,MoodEntry,User,DashboardLayout,ApiEnvelope,ApiError).paths— the client-facing REST surface. Not everysrc/app/api/**/route.tsis in the contract: most/api/admin/*routes, the OAuth connect/callback legs, and the webhook surfaces are not documented here. TheRetiredtag lists removed paths that answer410 Gone.
Conventions
- Successful responses use the envelope
{ data, error: null }. - Error responses use the envelope
{ data: null, error: '<message>' }. - Pagination uses
limit+offsetquery parameters and ameta: { total, limit, offset }block on the response. - All timestamps are ISO 8601 / RFC 3339 (
format: date-time) in UTC. - IDs are CUIDs (opaque strings).
Updating the spec
When adding or changing routes:
- Update the relevant route handler under
src/app/api/**/route.ts. - Change the Zod schemas and their
.meta()registrations undersrc/lib/openapi/(registry entry plus the matching module insrc/lib/openapi/routes/). KeepoperationIdcamelCase and stable; the iOS codegen keys off it. - Run
pnpm openapi:generateand commit the regeneratedopenapi.yamlalongside the schema change. CI'sopenapi:checkfails on drift. - Never edit the YAML directly and never bump
info.versionby hand; the version followspackage.jsonat generation time.