PulsHealth
Endpoints

Reference

API reference

Source: server/api/openapi.json on GitHub · edit this page

Base URL

http://127.0.0.1:8081 on the server, or your HTTPS proxy’s URL.

Authentication

Authorization: Bearer $PULS_API_TOKEN on every /v1 route.

Conventions

Epoch-millisecond timestamps, half-open [start, end) ranges, days in PULS_TIME_ZONE. Errors are {"error": "…"}.

Specification

openapi.jsonOpenAPI 3.1 · v1.0.0 · 18 endpoints

New to the API? Start with the overview: reaching the service, choosing the user, paging, errors and rate limits.

Discovery

Unauthenticated endpoints that describe the service and report its health.

Get the API index#

GET/ No authentication

operationId: getIndex

A JSON index pointing at the documentation, the OpenAPI document and the health check.

Responses

  • 200

    The index.

Response fields

  • namestring
  • versionstring
  • docsstring
  • openapistring
  • healthstring
  • authstring
  • userstring
  • descriptionstring
Request
curl "http://127.0.0.1:8081/"
Response · 200
{
  "name": "PulsHealth Product API",
  "version": "v1",
  "docs": "/docs",
  "openapi": "/openapi.json",
  "health": "/healthz",
  "auth": "Authorization: Bearer $PULS_API_TOKEN",
  "user": "Optional ?user=<uuid> on every /v1 route selects the user (default PULS_USER_ID; others need PULS_MULTI_USER=true); GET /v1/users lists them.",
  "description": "Read-only API for downstream products that use PulsHealth data."
}

Get the HTML reference#

GET/docs No authentication

operationId: getDocs

A self-contained HTML reference served by the deployment itself. The full documentation is at https://pulshealth.com/docs/api/.

Responses

  • 200

    HTML reference.

    text/html

Request
curl "http://127.0.0.1:8081/docs"

Get the OpenAPI document#

GET/openapi.json No authentication

operationId: getOpenAPI

This document. servers[0].url is the base URL the request arrived on, so a client generator or a ChatGPT Action imports it as is. Behind a proxy that host comes from X-Forwarded-Host only with TRUST_PROXY_HEADERS=true.

Responses

  • 200

    The OpenAPI 3.1 document.

    application/openapi+json

Request
curl "http://127.0.0.1:8081/openapi.json"

Check service health#

GET/healthz No authentication

operationId: getHealth

Liveness for a container health check. The database status is cached for two seconds, so polling it never holds a database connection.

Responses

  • 200

    The service and its database are up.

  • 503

    The database did not answer.

Response fields

  • okboolean
  • dbboolean

    Whether the database answered a ping in the last two seconds.

Request
curl "http://127.0.0.1:8081/healthz"
Response · 200
{
  "ok": true,
  "db": true
}

Users

Who the deployment answers for, and their profile.

List users#

GET/v1/users Bearer token

operationId: listUsers

Every user with the gate on (PULS_MULTI_USER=true); only the default user with it off. default is the user served when a request names none (PULS_USER_ID); multiUser says whether ?user= may name anyone else. timeZone is the zone every local day in this API's answers is cut in.

Responses

  • 200

    Users.

  • 400

    A parameter is missing, malformed or out of range. The message names it.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • usersarray of User
  • userIDstring (uuid)

    The user's id. Pass it as user= on any /v1 route.

  • namestring | null

    Name from the phone's profile, if set.

  • emailstring | null

    E-mail from the phone's profile, if set.

  • createdAtinteger (int64)

    When the server first saw this user. Epoch milliseconds (UTC).

  • lastSyncinteger (int64) | null

    Epoch milliseconds of the most recent batch; null when nothing has been uploaded.

  • batchesinteger (int64)

    Upload batches received.

  • uploadedSamplesinteger (int64)

    Sum of the sample counts the batches declared.

  • defaultstring (uuid)

    The user served when a request names none (PULS_USER_ID).

  • multiUserboolean

    Whether user= may name anyone else (PULS_MULTI_USER).

  • timeZonestring

    The IANA zone (PULS_TIME_ZONE) every local calendar day this API answers with is cut in; UTC when unset. The API refuses to start when it disagrees with the database's zone.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/users"
Response · 200
{
  "users": [
    {
      "userID": "5ea4d000-0000-4000-8000-000000000001",
      "name": "Alex Example",
      "email": "alex@example.com",
      "createdAt": 1767225600000,
      "lastSync": 1767225600000,
      "batches": 1824,
      "uploadedSamples": 2391044
    }
  ],
  "default": "5ea4d000-0000-4000-8000-000000000001",
  "multiUser": false,
  "timeZone": "Europe/Berlin"
}

Get the user's profile#

GET/v1/profile Bearer token

operationId: getProfile

The HealthKit characteristics the phone uploaded for this user: name, e-mail, date of birth and biological sex.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

Responses

  • 200

    Profile.

  • 400

    A parameter is missing, malformed or out of range. The message names it.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 404

    Nothing with that identifier exists for this user.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • userIDstring (uuid)

    The user's id.

  • namestring | null

    Name from the phone's profile, if set.

  • emailstring | null

    E-mail from the phone's profile, if set.

  • dateOfBirthinteger (int64) | null

    Date of birth as epoch milliseconds at UTC midnight.

  • biologicalSexstring | null

    HealthKit's biological sex, e.g. female, male, other.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/profile"
Response · 200
{
  "userID": "5ea4d000-0000-4000-8000-000000000001",
  "name": "Alex Example",
  "email": "alex@example.com",
  "dateOfBirth": 1767225600000,
  "biologicalSex": "female"
}

Catalog

Which HealthKit types hold data, with row counts and time spans.

List data types#

GET/v1/catalog/types Bearer token

operationId: listCatalogTypes

Every type with at least one row for this user, raw or aggregate, with its kind, canonical unit, row counts and time span. Counting rows is expensive, so the answer is cached per user for five minutes. After that the cached answer is still served, for up to an hour, while one background refresh replaces it, so new uploads can take a little longer than five minutes to appear; only a user's first request waits for the count.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

Responses

  • 200

    Catalog.

  • 400

    A parameter is missing, malformed or out of range. The message names it.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • typesarray of CatalogType
  • identifierstring

    HealthKit identifier.

  • kindstring

    quantity, category, workout, heartbeatSeries, ecg, stateOfMind, medicationDose or activitySummary.

  • unitstring | null

    Canonical unit every value of the type is stored in; null for types without one.

  • rowsinteger (int64)

    rawRows plus aggregateRows.

  • rawRowsinteger (int64)

    Individual samples stored.

  • aggregateRowsinteger (int64)

    Aggregate buckets the phone computed (what daily metrics are built from).

  • earliestinteger (int64) | null

    Start of the oldest row. Epoch milliseconds (UTC).

  • latestinteger (int64) | null

    Start of the newest row. Epoch milliseconds (UTC).

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/catalog/types"
Response · 200
{
  "types": [
    {
      "identifier": "HKQuantityTypeIdentifierStepCount",
      "kind": "quantity",
      "unit": "count",
      "rows": 184211,
      "rawRows": 171032,
      "aggregateRows": 13179,
      "earliest": 1767225600000,
      "latest": 1767229200000
    }
  ]
}

Metrics

Latest readings and one-value-per-day series, deduplicated across devices.

Get latest readings#

GET/v1/metrics/latest Bearer token

operationId: getLatestMetrics

The newest raw sample of each requested quantity type. Types with no data are left out, so the list can be shorter than types.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • typesstringqueryrequired

    Comma-separated HealthKit quantity identifiers, at most 50.

Responses

  • 200

    Latest metrics.

  • 400

    A parameter is missing, malformed or out of range. The message names it.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • metricsarray of LatestMetric
  • identifierstring

    HealthKit identifier.

  • unitstring | null

    Canonical unit of value.

  • valuenumber | null

    The reading.

  • timestampinteger (int64)

    Start of the sample. Epoch milliseconds (UTC).

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/metrics/latest?types=HKQuantityTypeIdentifierStepCount"
Response · 200
{
  "metrics": [
    {
      "identifier": "HKQuantityTypeIdentifierRestingHeartRate",
      "unit": "count/min",
      "value": 54,
      "timestamp": 1767225600000
    }
  ]
}

Get daily series#

GET/v1/metrics/daily Bearer token

operationId: getDailyMetrics

One value per local calendar day (in the server's PULS_TIME_ZONE) for each requested type, every day overlapping [start, end). Paged in days across the requested types: limit and offset count day rows, not metrics, in the order the response nests them (the requested types in request order, each ascending by day); nextOffset is offset plus the rows on the page and a page shorter than limit is the last. The default page holds a year of 27 types.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • typesstringqueryrequired

    Comma-separated HealthKit identifiers, at most 50; the response keeps this order.

  • startinteger (int64)queryrequired

    Inclusive start of the range, in epoch milliseconds.

  • endinteger (int64)queryrequired

    Exclusive end of the range, in epoch milliseconds. Must be after start.

  • limitintegerquery

    Day rows per page, across all requested types; larger values are clamped.

    Default: 10000 · Maximum: 50000

  • offsetintegerquery

    Rows to skip. Pass the previous page's nextOffset.

    Default: 0

Responses

  • 200

    Daily metrics.

  • 400

    Missing types or range, or an invalid limit or offset.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • metricsarray of DailyMetric
  • identifierstring

    HealthKit identifier.

  • unitstring | null

    Canonical unit of every value.

  • daysarray of object

    Days with a value, ascending.

  • datestring (date)

    Local calendar day (PULS_TIME_ZONE).

  • valuenumber | null

    The day's total for a cumulative type (steps), its mean for a discrete one (heart rate).

  • nextOffsetinteger

    Offset to pass for the next page; a page with fewer day rows than limit means the end.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/metrics/daily?types=HKQuantityTypeIdentifierStepCount&start=1735689600000&end=1738368000000"
Response · 200
{
  "metrics": [
    {
      "identifier": "HKQuantityTypeIdentifierStepCount",
      "unit": "count",
      "days": [
        {
          "date": "2026-01-01",
          "value": 8412
        }
      ]
    }
  ],
  "nextOffset": 31
}

Activity

Apple Activity rings, one row per local calendar day.

Get Activity rings#

GET/v1/activity/summary Bearer token

operationId: getActivitySummary

One row per local calendar day overlapping [start, end), as the Fitness app shows the rings.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • startinteger (int64)queryrequired

    Inclusive start of the range, in epoch milliseconds.

  • endinteger (int64)queryrequired

    Exclusive end of the range, in epoch milliseconds. Must be after start.

Responses

  • 200

    Activity days.

  • 400

    A parameter is missing, malformed or out of range. The message names it.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • daysarray of ActivityDay
  • datestring (date)

    Local calendar day.

  • moveKcalnumber | null

    Active energy burned, kcal.

  • moveGoalKcalnumber | null

    Move goal, kcal.

  • exerciseMinnumber | null

    Exercise minutes.

  • exerciseGoalMinnumber | null

    Exercise goal, minutes.

  • standHoursnumber | null

    Hours with a stand.

  • standGoalHoursnumber | null

    Stand goal, hours.

  • moveModeinteger | null

    HealthKit move mode: 1 active energy, 2 move time.

  • moveTimeMinnumber | null

    Move minutes (move-time mode only).

  • moveTimeGoalMinnumber | null

    Move goal, minutes (move-time mode only).

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/activity/summary?start=1735689600000&end=1738368000000"
Response · 200
{
  "days": [
    {
      "date": "2026-01-01",
      "moveKcal": 512.4,
      "moveGoalKcal": 600,
      "exerciseMin": 34,
      "exerciseGoalMin": 30,
      "standHours": 11,
      "standGoalHours": 12,
      "moveMode": 1,
      "moveTimeMin": null,
      "moveTimeGoalMin": null
    }
  ]
}

Workouts

Workout summaries, detail, and the per-second streams recorded during them.

List workouts#

GET/v1/workouts Bearer token

operationId: listWorkouts

Workouts newest first, optionally limited to [start, end) on the start time and to one activity type.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • startinteger (int64)query

    Only workouts starting at or after this instant. Epoch milliseconds.

  • endinteger (int64)query

    Only workouts starting before this instant. Epoch milliseconds.

  • activityTypestringquery

    Only this HealthKit activity type, e.g. running.

  • limitintegerquery

    Workouts per page; larger values are clamped.

    Default: 50 · Maximum: 200

  • offsetintegerquery

    Rows to skip. Pass the previous page's nextOffset.

    Default: 0

Responses

  • 200

    Workouts.

  • 400

    An invalid range, limit or offset.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • workoutsarray of WorkoutSummary
  • uuidstring (uuid)

    HealthKit UUID.

  • activityTypestring

    HealthKit activity type name.

  • startinteger (int64)

    Start. Epoch milliseconds (UTC).

  • endinteger (int64)

    End. Epoch milliseconds (UTC).

  • durationSnumber | null

    Duration, seconds.

  • distanceMnumber | null

    Total distance, metres; null when none was recorded.

  • energyKcalnumber | null

    Active energy, kcal.

  • hasRouteboolean

    Whether GPS route points were synced.

  • availableMetricsarray of string

    HealthKit identifiers with a stream in GET /v1/workouts/{uuid}/series.

  • nextOffsetinteger

    Offset to pass for the next page; a page shorter than limit is the last.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/workouts"
Response · 200
{
  "workouts": [
    {
      "uuid": "5ea4d000-0000-4000-8000-000000000001",
      "activityType": "running",
      "start": 1767225600000,
      "end": 1767229200000,
      "durationS": 2712.5,
      "distanceM": 8046.7,
      "energyKcal": 612.3,
      "hasRoute": true,
      "availableMetrics": [
        "HKQuantityTypeIdentifierHeartRate",
        "HKQuantityTypeIdentifierRunningPower"
      ]
    }
  ],
  "nextOffset": 50
}

Get a workout#

GET/v1/workouts/{uuid} Bearer token

operationId: getWorkout

One workout with HealthKit's per-type statistics, its events and, for a multisport workout, its activities.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • uuidstring (uuid)pathrequired

    The workout's HealthKit UUID, from GET /v1/workouts.

Responses

  • 200

    Workout detail.

  • 400

    Invalid UUID.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 404

    Nothing with that identifier exists for this user.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • uuidstring (uuid)

    HealthKit UUID.

  • activityTypestring

    HealthKit activity type name.

  • startinteger (int64)

    Start. Epoch milliseconds (UTC).

  • endinteger (int64)

    End. Epoch milliseconds (UTC).

  • durationSnumber | null

    Duration, seconds.

  • distanceMnumber | null

    Total distance, metres; null when none was recorded.

  • energyKcalnumber | null

    Active energy, kcal.

  • hasRouteboolean

    Whether GPS route points were synced.

  • availableMetricsarray of string

    HealthKit identifiers with a stream in GET /v1/workouts/{uuid}/series.

  • statisticsDetailmap of object

    Per-type statistics HealthKit computed for the workout, keyed by HealthKit identifier, in each type's canonical unit.

  • eventsarray of object

    Workout events (pauses, laps, segments) as HealthKit reported them.

  • activitiesarray of object

    Sub-activities of a multisport workout.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/workouts/5ea4d000-0000-4000-8000-000000000001"
Response · 200
{
  "uuid": "5ea4d000-0000-4000-8000-000000000001",
  "activityType": "running",
  "start": 1767225600000,
  "end": 1767229200000,
  "durationS": 2712.5,
  "distanceM": 8046.7,
  "energyKcal": 612.3,
  "hasRoute": true,
  "availableMetrics": [
    "HKQuantityTypeIdentifierHeartRate",
    "HKQuantityTypeIdentifierRunningPower"
  ],
  "statisticsDetail": {
    "HKQuantityTypeIdentifierHeartRate": {
      "min": 92,
      "avg": 151.6,
      "max": 178
    },
    "HKQuantityTypeIdentifierActiveEnergyBurned": {
      "sum": 612.3
    }
  },
  "events": [
    {
      "type": "pause",
      "start": 1767226800000,
      "end": 1767226860000
    }
  ],
  "activities": []
}

Get a workout's streams#

GET/v1/workouts/{uuid}/series Bearer token

operationId: getWorkoutSeries

Per-second curves recorded during the workout, each downsampled to at most maxPoints points by bucket-averaging while keeping the first and last point.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • uuidstring (uuid)pathrequired

    The workout's HealthKit UUID, from GET /v1/workouts.

  • typesstringquery

    Comma-separated HealthKit identifiers, at most 50; omit for every recorded stream.

  • maxPointsintegerquery

    Points per stream after downsampling; larger values are clamped.

    Default: 500 · Maximum: 5000

Responses

  • 200

    Workout series.

  • 400

    Invalid UUID or parameters.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 404

    Nothing with that identifier exists for this user.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • uuidstring (uuid)
  • startinteger (int64)

    Workout start. Epoch milliseconds (UTC).

  • endinteger (int64)

    Workout end. Epoch milliseconds (UTC).

  • maxPointsinteger

    The point cap applied to each stream.

  • seriesarray of WorkoutSeries
  • typestring

    HealthKit identifier of the stream.

  • unitstring | null

    Canonical unit of every value.

  • totalPointsinteger

    Points recorded before downsampling.

  • pointsarray of [integer (int64), number]

    [epoch milliseconds, value] pairs, ordered by time.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/workouts/5ea4d000-0000-4000-8000-000000000001/series"
Response · 200
{
  "uuid": "5ea4d000-0000-4000-8000-000000000001",
  "start": 1767225600000,
  "end": 1767229200000,
  "maxPoints": 500,
  "series": [
    {
      "type": "HKQuantityTypeIdentifierHeartRate",
      "unit": "count/min",
      "totalPoints": 2713,
      "points": [
        [
          1767225600000,
          142
        ]
      ]
    }
  ]
}

Sleep

Sleep sessions, attributed to the wake-up day, with stage minutes.

List sleep nights#

GET/v1/sleep/daily Bearer token

operationId: getSleepNights

One row per sleep session, attributed to the local calendar day it ended on. Sessions are split on gaps over three hours, and every local day overlapping [start, end) is covered.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • startinteger (int64)queryrequired

    Inclusive start of the range, in epoch milliseconds.

  • endinteger (int64)queryrequired

    Exclusive end of the range, in epoch milliseconds. Must be after start.

Responses

  • 200

    Sleep nights.

  • 400

    Invalid range, or a range over 366 days.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • nightsarray of SleepNight
  • datestring (date)

    Local wake-up day.

  • startinteger (int64)

    First sample of the session, any source. Epoch milliseconds (UTC).

  • endinteger (int64)

    Last sample of the session, any source. Epoch milliseconds (UTC).

  • inBedMinutesnumber

    Highest single-source in-bed total, minutes.

  • asleepMinutesnumber

    Core + deep + REM + unspecified of the winning source, minutes.

  • stagesSleepStages

    Minutes per stage, from the single source that recorded the most sleep. asleepMinutes is core + deep + rem + unspecified; awake is time awake during the session and is not part of it.

  • corenumber
  • deepnumber
  • remnumber
  • unspecifiednumber
  • awakenumber
  • sourcesinteger

    Distinct sources that contributed samples to this session.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/sleep/daily?start=1735689600000&end=1738368000000"
Response · 200
{
  "nights": [
    {
      "date": "2026-01-01",
      "start": 1767221400000,
      "end": 1767249300000,
      "inBedMinutes": 462,
      "asleepMinutes": 431.5,
      "stages": {
        "core": 238,
        "deep": 71.5,
        "rem": 122,
        "unspecified": 0,
        "awake": 18
      },
      "sources": 2
    }
  ]
}

Samples

Individual HealthKit records of one type, exactly as synced.

List raw samples#

GET/v1/samples Bearer token

operationId: getSamples

Individual HealthKit records, ordered by start time, not deduplicated across devices. The range is [start, end) on the sample start time and may not exceed 31 days.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • typestringqueryrequired

    Exactly one HealthKit identifier (see /v1/catalog/types).

  • startinteger (int64)queryrequired

    Inclusive start of the range, in epoch milliseconds.

  • endinteger (int64)queryrequired

    Exclusive end of the range, in epoch milliseconds. Must be after start.

  • limitintegerquery

    Samples per page; larger values are clamped.

    Default: 1000 · Maximum: 5000

  • offsetintegerquery

    Rows to skip. Pass the previous page's nextOffset.

    Default: 0

Responses

  • 200

    Samples.

  • 400

    Unknown type, a non-sample type, or a range over 31 days.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • typestring

    The requested HealthKit identifier.

  • kindstring
  • unitstring | null

    Canonical unit of every quantity value; null for category types.

  • samplesarray of Sample
  • uuidstring (uuid)

    HealthKit UUID.

  • startinteger (int64)

    Start. Epoch milliseconds (UTC).

  • endinteger (int64)

    End. Epoch milliseconds (UTC).

  • valuenumber | null

    Quantity value in the page's canonical unit, or the category type's integer enum value.

  • labelstring | null

    Category types only: the HealthKit name of value.

  • sourcestring | null

    Name of the app or device that recorded it.

  • nextOffsetinteger

    Offset to pass for the next page; a short page means the end.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/samples?type=HKQuantityTypeIdentifierStepCount&start=1735689600000&end=1738368000000"
Response · 200
{
  "type": "HKQuantityTypeIdentifierHeartRate",
  "kind": "quantity",
  "unit": "count/min",
  "samples": [
    {
      "uuid": "5ea4d000-0000-4000-8000-000000000001",
      "start": 1767225600000,
      "end": 1767229200000,
      "value": 72,
      "label": null,
      "source": "Apple Watch"
    }
  ],
  "nextOffset": 1000
}

State of Mind

Momentary emotions and daily moods logged on iOS 18 and later.

List State of Mind entries#

GET/v1/state-of-mind Bearer token

operationId: getStateOfMind

Entries whose instant falls on a local calendar day overlapping [start, end), oldest first. At most 366 days per request.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • startinteger (int64)queryrequired

    Inclusive start of the range, in epoch milliseconds.

  • endinteger (int64)queryrequired

    Exclusive end of the range, in epoch milliseconds. Must be after start.

Responses

  • 200

    Entries.

  • 400

    Invalid range, or a range over 366 days.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • entriesarray of StateOfMindEntry
  • uuidstring (uuid)
  • datestring (date)

    Local calendar day of the entry.

  • timestampinteger (int64)

    When it was logged. Epoch milliseconds (UTC).

  • kindstring

    momentaryEmotion or dailyMood.

  • valencenumber | null

    -1 (very unpleasant) to +1 (very pleasant).

  • valenceClassificationstring | null

    Apple's band for valence, e.g. slightlyPleasant.

  • labelsarray of string

    Feelings picked, e.g. calm, stressed.

  • associationsarray of string

    What they are about, e.g. work, family.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/state-of-mind?start=1735689600000&end=1738368000000"
Response · 200
{
  "entries": [
    {
      "uuid": "5ea4d000-0000-4000-8000-000000000001",
      "date": "2026-01-01",
      "timestamp": 1767225600000,
      "kind": "momentaryEmotion",
      "valence": 0.42,
      "valenceClassification": "slightlyPleasant",
      "labels": [
        "calm",
        "content"
      ],
      "associations": [
        "work"
      ]
    }
  ]
}

Summary

Recent data as one short page for a chat without an MCP connection.

Get a recent summary#

GET/v1/summary Bearer token

operationId: getSummary

The last range calendar days (7d by default; ending today in the server's PULS_TIME_ZONE) as one markdown page of under sixty lines, meant to be pasted into a chat that has no MCP connection: a header naming the user, the days and the zone, then a section for each kind of data that exists — activity (steps, active energy, exercise minutes, stand hours as daily means and totals), heart (resting heart rate, HRV), sleep (time asleep per night), workouts (count, total time, distance, most frequent activities), body (newest weight and body fat) — and a coverage line (last sync, days with data, and the reminder that daily figures are already deduplicated across devices). Every figure comes from the daily surfaces the other endpoints serve, never from raw samples. format=json returns the same numbers as a Summary object.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • rangestringquery

    How many calendar days, ending today, the summary covers.

    One of: 7d, 14d, 30d, 90d · Default: 7d

  • formatstringquery

    markdown for the page (text/markdown), json for the Summary object it is rendered from.

    One of: markdown, json · Default: markdown

Responses

  • 200

    The summary.

    text/markdown

  • 400

    A range or format outside the accepted values.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 504

    The query ran past its 30-second limit and was cancelled ({"error": "query took too long; narrow the range"}). Narrow the range or page smaller.

Response fields

  • userIDstring (uuid)
  • namestring | null
  • rangestring
  • daysinteger
  • startDatestring (date)

    First local calendar day covered.

  • endDatestring (date)

    Last local calendar day covered: today in timeZone.

  • generatedAtinteger (int64)

    When the summary was computed. Epoch milliseconds (UTC).

  • timeZonestring

    The IANA zone (PULS_TIME_ZONE) whose calendar cut the days.

  • activityobject
  • stepsSummaryStat

    One daily series over the summary's range: the days that had a value and the mean, minimum and maximum of those days. total is present for cumulative series only (steps, energy, exercise minutes). source names the table the values came from.

  • unitstring
  • daysinteger
  • meannumber
  • minnumber
  • maxnumber
  • totalnumber
  • sourcestring
  • activeEnergySummaryStat

    One daily series over the summary's range: the days that had a value and the mean, minimum and maximum of those days. total is present for cumulative series only (steps, energy, exercise minutes). source names the table the values came from.

  • unitstring
  • daysinteger
  • meannumber
  • minnumber
  • maxnumber
  • totalnumber
  • sourcestring
  • exerciseSummaryStat

    One daily series over the summary's range: the days that had a value and the mean, minimum and maximum of those days. total is present for cumulative series only (steps, energy, exercise minutes). source names the table the values came from.

  • unitstring
  • daysinteger
  • meannumber
  • minnumber
  • maxnumber
  • totalnumber
  • sourcestring
  • standSummaryStat

    One daily series over the summary's range: the days that had a value and the mean, minimum and maximum of those days. total is present for cumulative series only (steps, energy, exercise minutes). source names the table the values came from.

  • unitstring
  • daysinteger
  • meannumber
  • minnumber
  • maxnumber
  • totalnumber
  • sourcestring
  • heartobject
  • restingHeartRateSummaryStat

    One daily series over the summary's range: the days that had a value and the mean, minimum and maximum of those days. total is present for cumulative series only (steps, energy, exercise minutes). source names the table the values came from.

  • unitstring
  • daysinteger
  • meannumber
  • minnumber
  • maxnumber
  • totalnumber
  • sourcestring
  • hrvSDNNSummaryStat

    One daily series over the summary's range: the days that had a value and the mean, minimum and maximum of those days. total is present for cumulative series only (steps, energy, exercise minutes). source names the table the values came from.

  • unitstring
  • daysinteger
  • meannumber
  • minnumber
  • maxnumber
  • totalnumber
  • sourcestring
  • sleepobject

    The longest sleep session of each wake-up day in the range.

  • nightsinteger
  • meanAsleepMinutesnumber
  • minAsleepMinutesnumber
  • maxAsleepMinutesnumber
  • workoutsobject
  • countinteger
  • totalMinutesnumber
  • totalDistanceMnumber

    Absent when no workout in the range recorded a distance.

  • byActivityTypearray of object

    Most frequent first, at most three.

  • activityTypestring
  • countinteger
  • bodyobject
  • weightSummaryReading

    The newest raw sample of a body metric, whenever it was taken.

  • valuenumber
  • unitstring
  • timestampinteger (int64)

    When the reading was taken. Epoch milliseconds (UTC).

  • bodyFatSummaryReading

    The newest raw sample of a body metric, whenever it was taken.

  • valuenumber
  • unitstring
  • timestampinteger (int64)

    When the reading was taken. Epoch milliseconds (UTC).

  • coverageobject
  • lastSyncinteger (int64) | null

    Epoch milliseconds of the most recent upload; null when nothing has been uploaded.

  • daysWithDatainteger

    Days in the range on which at least one section has a value.

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/summary"
Response · 200
{
  "userID": "5ea4d000-0000-4000-8000-000000000001",
  "name": "Alex Example",
  "range": "7d",
  "days": 7,
  "startDate": "2026-01-01",
  "endDate": "2026-01-07",
  "generatedAt": 1767800000000,
  "timeZone": "Europe/London",
  "activity": {
    "steps": {
      "unit": "count",
      "days": 7,
      "mean": 9214,
      "min": 4120,
      "max": 15873,
      "total": 64498,
      "source": "metric_daily"
    },
    "exercise": {
      "unit": "min",
      "days": 7,
      "mean": 38,
      "min": 12,
      "max": 71,
      "total": 266,
      "source": "activity_summaries"
    }
  },
  "heart": {
    "restingHeartRate": {
      "unit": "count/min",
      "days": 7,
      "mean": 54.3,
      "min": 51,
      "max": 58,
      "source": "metric_daily"
    }
  },
  "sleep": {
    "nights": 7,
    "meanAsleepMinutes": 428.6,
    "minAsleepMinutes": 371,
    "maxAsleepMinutes": 489
  },
  "workouts": {
    "count": 4,
    "totalMinutes": 197.5,
    "totalDistanceM": 31420,
    "byActivityType": [
      {
        "activityType": "running",
        "count": 3
      },
      {
        "activityType": "yoga",
        "count": 1
      }
    ]
  },
  "body": {
    "weight": {
      "value": 71.8,
      "unit": "kg",
      "timestamp": 1767690000000
    }
  },
  "coverage": {
    "lastSync": 1767799000000,
    "daysWithData": 7
  }
}

Export

Whole ranges streamed as CSV or JSONL files.

Export a dataset#

GET/v1/export Bearer token

operationId: exportDataset

Streams a whole range as a file (Transfer-Encoding: chunked, Content-Disposition: attachment) instead of a JSON document. CSV opens the file with a header row; JSONL writes one JSON object per line whose keys are the same column names. Field names match the JSON endpoints; where an endpoint nests (a metric's days, a night's stages) the export flattens, repeating the identifying fields on every row and naming a nested field by its path. Ranges are capped at 31 days for samples (as /v1/samples is) and 366 days for every other dataset — the same cap /v1/sleep/daily and /v1/state-of-mind apply, and deliberately stricter than /v1/metrics/daily and /v1/workouts, which are bounded by a page size rather than by their range, and /v1/activity/summary, which is one small row per day. The daily_metrics and workouts datasets return the whole range (workouts newest first); limit and offset are not used here. At most 2 exports run at once, because each holds a database connection for the length of the download; over that is a 503 with Retry-After. An export ends after 30 minutes, and one whose client stops reading for a minute is dropped; either way the connection is aborted, so the file is a visibly failed download rather than a short one. The 30-second query limit of the other routes does not apply.

Parameters

  • userstring (uuid)query

    The user to answer for. Defaults to the deployment's PULS_USER_ID. Naming anyone else needs PULS_MULTI_USER=true, otherwise the answer is 403. GET /v1/users lists them.

  • formatstringqueryrequired

    csv for a spreadsheet (header row first), jsonl for one JSON object per line.

    One of: csv, jsonl

  • datasetstringqueryrequired

    Which rows to export. Each dataset's columns are the matching JSON endpoint's field names.

    One of: daily_metrics, samples, workouts, sleep, activity, state_of_mind

  • startinteger (int64)queryrequired

    Inclusive start of the range, in epoch milliseconds.

  • endinteger (int64)queryrequired

    Exclusive end of the range, in epoch milliseconds. Must be after start.

  • typesstringquery

    daily_metrics only, and required there: comma-separated HealthKit identifiers, at most 50.

  • typestringquery

    samples only, and required there: exactly one HealthKit identifier.

  • activityTypestringquery

    workouts only: keep one activity type.

Responses

  • 200

    The dataset, streamed as an attachment named puls-<dataset>-<start>-<end>.<csv|jsonl>.

    text/csv · application/x-ndjson

  • 400

    Missing or invalid format or dataset, a missing dataset parameter, an unknown type, or a range over the dataset's cap.

  • 401

    The bearer token is missing or wrong. Counts against the failed-authentication limit.

    WWW-Authenticate header

  • 403

    user names someone other than the default user while PULS_MULTI_USER is off.

  • 429

    Too many failed authentications from this client address. The request was refused before the token was checked.

    Retry-After header

  • 500

    The query failed. Details are in the server log, never in the response.

  • 503

    Too many exports already in progress; retry after the Retry-After interval.

    Retry-After header

Request
curl -H "Authorization: Bearer $PULS_API_TOKEN" \
  "http://127.0.0.1:8081/v1/export?format=csv&dataset=daily_metrics&start=1735689600000&end=1738368000000"