{
  "openapi": "3.1.0",
  "info": {
    "title": "PulsHealth Product API",
    "version": "1.0.0",
    "summary": "Read-only HTTP API over the health data a PulsHealth server holds.",
    "description": "Read-only JSON API over the HealthKit data your iPhone synced into your own PulsHealth server: daily metrics, activity rings, workouts and their streams, sleep, raw samples, State of Mind, a chat-ready summary and bulk export.\n\nEvery data route needs `Authorization: Bearer <PULS_API_TOKEN>` and answers for one user. Timestamps are epoch milliseconds, ranges are half-open `[start, end)`, and day-grained answers use the server's `PULS_TIME_ZONE` calendar. Errors are `{\"error\": \"...\"}` with a conventional status code.\n\nGuide: https://pulshealth.com/docs/api/",
    "contact": {
      "name": "PulsHealth",
      "url": "https://github.com/PulsHealth/pulshealth/issues"
    },
    "license": {
      "name": "Apache-2.0",
      "identifier": "Apache-2.0"
    }
  },
  "externalDocs": {
    "description": "Guide: authentication, conventions, paging and errors",
    "url": "https://pulshealth.com/docs/api/"
  },
  "servers": [
    {
      "url": "http://127.0.0.1:8081",
      "description": "The address the API listens on, on the server itself. Replace it with your HTTPS proxy's URL, or fetch /openapi.json from your deployment, which names it for you."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Discovery",
      "description": "Unauthenticated endpoints that describe the service and report its health."
    },
    {
      "name": "Users",
      "description": "Who the deployment answers for, and their profile."
    },
    {
      "name": "Catalog",
      "description": "Which HealthKit types hold data, with row counts and time spans."
    },
    {
      "name": "Metrics",
      "description": "Latest readings and one-value-per-day series, deduplicated across devices."
    },
    {
      "name": "Activity",
      "description": "Apple Activity rings, one row per local calendar day."
    },
    {
      "name": "Workouts",
      "description": "Workout summaries, detail, and the per-second streams recorded during them."
    },
    {
      "name": "Sleep",
      "description": "Sleep sessions, attributed to the wake-up day, with stage minutes."
    },
    {
      "name": "Samples",
      "description": "Individual HealthKit records of one type, exactly as synced."
    },
    {
      "name": "State of Mind",
      "description": "Momentary emotions and daily moods logged on iOS 18 and later."
    },
    {
      "name": "Summary",
      "description": "Recent data as one short page for a chat without an MCP connection."
    },
    {
      "name": "Export",
      "description": "Whole ranges streamed as CSV or JSONL files."
    }
  ],
  "paths": {
    "/": {
      "get": {
        "operationId": "getIndex",
        "tags": [
          "Discovery"
        ],
        "summary": "Get the API index",
        "description": "A JSON index pointing at the documentation, the OpenAPI document and the health check.",
        "security": [],
        "responses": {
          "200": {
            "description": "The index.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Index"
                }
              }
            }
          }
        }
      }
    },
    "/docs": {
      "get": {
        "operationId": "getDocs",
        "tags": [
          "Discovery"
        ],
        "summary": "Get the HTML reference",
        "description": "A self-contained HTML reference served by the deployment itself. The full documentation is at https://pulshealth.com/docs/api/.",
        "security": [],
        "responses": {
          "200": {
            "description": "HTML reference.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "operationId": "getOpenAPI",
        "tags": [
          "Discovery"
        ],
        "summary": "Get the OpenAPI document",
        "description": "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`.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI 3.1 document.",
            "content": {
              "application/openapi+json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/healthz": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "Discovery"
        ],
        "summary": "Check service health",
        "description": "Liveness for a container health check. The database status is cached for two seconds, so polling it never holds a database connection.",
        "security": [],
        "responses": {
          "200": {
            "description": "The service and its database are up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          },
          "503": {
            "description": "The database did not answer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        }
      }
    },
    "/v1/users": {
      "get": {
        "operationId": "listUsers",
        "tags": [
          "Users"
        ],
        "summary": "List users",
        "description": "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": {
            "description": "Users.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UsersResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/profile": {
      "get": {
        "operationId": "getProfile",
        "tags": [
          "Users"
        ],
        "summary": "Get the user's profile",
        "description": "The HealthKit characteristics the phone uploaded for this user: name, e-mail, date of birth and biological sex.",
        "parameters": [
          {
            "$ref": "#/components/parameters/User"
          }
        ],
        "responses": {
          "200": {
            "description": "Profile.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Profile"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/catalog/types": {
      "get": {
        "operationId": "listCatalogTypes",
        "tags": [
          "Catalog"
        ],
        "summary": "List data types",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          }
        ],
        "responses": {
          "200": {
            "description": "Catalog.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "types": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CatalogType"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/metrics/latest": {
      "get": {
        "operationId": "getLatestMetrics",
        "tags": [
          "Metrics"
        ],
        "summary": "Get latest readings",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "name": "types",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated HealthKit quantity identifiers, at most 50."
          }
        ],
        "responses": {
          "200": {
            "description": "Latest metrics.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "metrics": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LatestMetric"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/metrics/daily": {
      "get": {
        "operationId": "getDailyMetrics",
        "tags": [
          "Metrics"
        ],
        "summary": "Get daily series",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "name": "types",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated HealthKit identifiers, at most 50; the response keeps this order."
          },
          {
            "$ref": "#/components/parameters/Start"
          },
          {
            "$ref": "#/components/parameters/End"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 10000,
              "maximum": 50000
            },
            "description": "Day rows per page, across all requested types; larger values are clamped."
          },
          {
            "$ref": "#/components/parameters/Offset"
          }
        ],
        "responses": {
          "200": {
            "description": "Daily metrics.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "metrics": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DailyMetric"
                      }
                    },
                    "nextOffset": {
                      "type": "integer",
                      "description": "Offset to pass for the next page; a page with fewer day rows than `limit` means the end.",
                      "examples": [
                        31
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing types or range, or an invalid `limit` or `offset`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/activity/summary": {
      "get": {
        "operationId": "getActivitySummary",
        "tags": [
          "Activity"
        ],
        "summary": "Get Activity rings",
        "description": "One row per local calendar day overlapping `[start, end)`, as the Fitness app shows the rings.",
        "parameters": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "$ref": "#/components/parameters/Start"
          },
          {
            "$ref": "#/components/parameters/End"
          }
        ],
        "responses": {
          "200": {
            "description": "Activity days.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "days": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ActivityDay"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/workouts": {
      "get": {
        "operationId": "listWorkouts",
        "tags": [
          "Workouts"
        ],
        "summary": "List workouts",
        "description": "Workouts newest first, optionally limited to `[start, end)` on the start time and to one activity type.",
        "parameters": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "name": "start",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            },
            "description": "Only workouts starting at or after this instant. Epoch milliseconds."
          },
          {
            "name": "end",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            },
            "description": "Only workouts starting before this instant. Epoch milliseconds."
          },
          {
            "name": "activityType",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Only this HealthKit activity type, e.g. `running`."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            },
            "description": "Workouts per page; larger values are clamped."
          },
          {
            "$ref": "#/components/parameters/Offset"
          }
        ],
        "responses": {
          "200": {
            "description": "Workouts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "workouts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WorkoutSummary"
                      }
                    },
                    "nextOffset": {
                      "type": "integer",
                      "description": "Offset to pass for the next page; a page shorter than `limit` is the last.",
                      "examples": [
                        50
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "An invalid range, limit or offset.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/workouts/{uuid}": {
      "get": {
        "operationId": "getWorkout",
        "tags": [
          "Workouts"
        ],
        "summary": "Get a workout",
        "description": "One workout with HealthKit's per-type statistics, its events and, for a multisport workout, its activities.",
        "parameters": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "$ref": "#/components/parameters/WorkoutUUID"
          }
        ],
        "responses": {
          "200": {
            "description": "Workout detail.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkoutDetail"
                }
              }
            }
          },
          "400": {
            "description": "Invalid UUID.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/workouts/{uuid}/series": {
      "get": {
        "operationId": "getWorkoutSeries",
        "tags": [
          "Workouts"
        ],
        "summary": "Get a workout's streams",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "$ref": "#/components/parameters/WorkoutUUID"
          },
          {
            "name": "types",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated HealthKit identifiers, at most 50; omit for every recorded stream."
          },
          {
            "name": "maxPoints",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 500,
              "maximum": 5000
            },
            "description": "Points per stream after downsampling; larger values are clamped."
          }
        ],
        "responses": {
          "200": {
            "description": "Workout series.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkoutSeriesResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid UUID or parameters.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/sleep/daily": {
      "get": {
        "operationId": "getSleepNights",
        "tags": [
          "Sleep"
        ],
        "summary": "List sleep nights",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "$ref": "#/components/parameters/Start"
          },
          {
            "$ref": "#/components/parameters/End"
          }
        ],
        "responses": {
          "200": {
            "description": "Sleep nights.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "nights": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SleepNight"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid range, or a range over 366 days.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/samples": {
      "get": {
        "operationId": "getSamples",
        "tags": [
          "Samples"
        ],
        "summary": "List raw samples",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "name": "type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Exactly one HealthKit identifier (see `/v1/catalog/types`)."
          },
          {
            "$ref": "#/components/parameters/Start"
          },
          {
            "$ref": "#/components/parameters/End"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 1000,
              "maximum": 5000
            },
            "description": "Samples per page; larger values are clamped."
          },
          {
            "$ref": "#/components/parameters/Offset"
          }
        ],
        "responses": {
          "200": {
            "description": "Samples.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SamplesPage"
                }
              }
            }
          },
          "400": {
            "description": "Unknown type, a non-sample type, or a range over 31 days.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/state-of-mind": {
      "get": {
        "operationId": "getStateOfMind",
        "tags": [
          "State of Mind"
        ],
        "summary": "List State of Mind entries",
        "description": "Entries whose instant falls on a local calendar day overlapping `[start, end)`, oldest first. At most 366 days per request.",
        "parameters": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "$ref": "#/components/parameters/Start"
          },
          {
            "$ref": "#/components/parameters/End"
          }
        ],
        "responses": {
          "200": {
            "description": "Entries.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "entries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/StateOfMindEntry"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid range, or a range over 366 days.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/summary": {
      "get": {
        "operationId": "getSummary",
        "tags": [
          "Summary"
        ],
        "summary": "Get a recent summary",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "name": "range",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "7d",
                "14d",
                "30d",
                "90d"
              ],
              "default": "7d"
            },
            "description": "How many calendar days, ending today, the summary covers."
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "markdown",
                "json"
              ],
              "default": "markdown"
            },
            "description": "markdown for the page (text/markdown), json for the Summary object it is rendered from."
          }
        ],
        "responses": {
          "200": {
            "description": "The summary.",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Summary"
                }
              }
            }
          },
          "400": {
            "description": "A range or format outside the accepted values.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "504": {
            "$ref": "#/components/responses/GatewayTimeout"
          }
        }
      }
    },
    "/v1/export": {
      "get": {
        "operationId": "exportDataset",
        "tags": [
          "Export"
        ],
        "summary": "Export a dataset",
        "description": "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": [
          {
            "$ref": "#/components/parameters/User"
          },
          {
            "name": "format",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "csv",
                "jsonl"
              ]
            },
            "description": "`csv` for a spreadsheet (header row first), `jsonl` for one JSON object per line."
          },
          {
            "name": "dataset",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "daily_metrics",
                "samples",
                "workouts",
                "sleep",
                "activity",
                "state_of_mind"
              ]
            },
            "description": "Which rows to export. Each dataset's columns are the matching JSON endpoint's field names."
          },
          {
            "$ref": "#/components/parameters/Start"
          },
          {
            "$ref": "#/components/parameters/End"
          },
          {
            "name": "types",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "`daily_metrics` only, and required there: comma-separated HealthKit identifiers, at most 50."
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "samples only, and required there: exactly one HealthKit identifier."
          },
          {
            "name": "activityType",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "workouts only: keep one activity type."
          }
        ],
        "responses": {
          "200": {
            "description": "The dataset, streamed as an attachment named puls-<dataset>-<start>-<end>.<`csv`|`jsonl`>.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              },
              "application/x-ndjson": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid format or dataset, a missing dataset parameter, an unknown type, or a range over the dataset's cap.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "description": "Too many exports already in progress; retry after the `Retry-After` interval.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait; always 60."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`PULS_API_TOKEN` from the server's `.env`. Not the phone's ingest token."
      }
    },
    "parameters": {
      "User": {
        "name": "user",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "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."
      },
      "Start": {
        "name": "start",
        "in": "query",
        "required": true,
        "schema": {
          "type": "integer",
          "format": "int64",
          "minimum": 0,
          "maximum": 253402300799999
        },
        "description": "Inclusive start of the range, in epoch milliseconds.",
        "example": 1735689600000
      },
      "End": {
        "name": "end",
        "in": "query",
        "required": true,
        "schema": {
          "type": "integer",
          "format": "int64",
          "minimum": 0,
          "maximum": 253402300799999
        },
        "description": "Exclusive end of the range, in epoch milliseconds. Must be after `start`.",
        "example": 1738368000000
      },
      "Offset": {
        "name": "offset",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        },
        "description": "Rows to skip. Pass the previous page's `nextOffset`."
      },
      "WorkoutUUID": {
        "name": "uuid",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "The workout's HealthKit UUID, from `GET /v1/workouts`."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "A parameter is missing, malformed or out of range. The message names it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "The bearer token is missing or wrong. Counts against the failed-authentication limit.",
        "headers": {
          "WWW-Authenticate": {
            "schema": {
              "type": "string"
            },
            "description": "Always `Bearer`."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "`user` names someone other than the default user while `PULS_MULTI_USER` is off.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Nothing with that identifier exists for this user.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Too many failed authentications from this client address. The request was refused before the token was checked.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds until the next attempt will be evaluated."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "InternalError": {
        "description": "The query failed. Details are in the server log, never in the response.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "GatewayTimeout": {
        "description": "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.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Profile": {
        "type": "object",
        "properties": {
          "userID": {
            "type": "string",
            "format": "uuid",
            "description": "The user's id."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Name from the phone's profile, if set.",
            "examples": [
              "Alex Example"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "E-mail from the phone's profile, if set.",
            "examples": [
              "alex@example.com"
            ]
          },
          "dateOfBirth": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Date of birth as epoch milliseconds at UTC midnight."
          },
          "biologicalSex": {
            "type": [
              "string",
              "null"
            ],
            "description": "HealthKit's biological sex, e.g. `female`, `male`, `other`.",
            "examples": [
              "female"
            ]
          }
        },
        "description": "The HealthKit characteristics the phone last uploaded. Fields the person never filled in are null."
      },
      "User": {
        "type": "object",
        "description": "One user the deployment answers for, with what the batches log says about their uploads.",
        "properties": {
          "userID": {
            "type": "string",
            "format": "uuid",
            "description": "The user's id. Pass it as `user=` on any `/v1` route."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Name from the phone's profile, if set.",
            "examples": [
              "Alex Example"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "E-mail from the phone's profile, if set.",
            "examples": [
              "alex@example.com"
            ]
          },
          "createdAt": {
            "type": "integer",
            "format": "int64",
            "description": "When the server first saw this user. Epoch milliseconds (UTC)."
          },
          "lastSync": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Epoch milliseconds of the most recent batch; null when nothing has been uploaded."
          },
          "batches": {
            "type": "integer",
            "format": "int64",
            "description": "Upload batches received.",
            "examples": [
              1824
            ]
          },
          "uploadedSamples": {
            "type": "integer",
            "format": "int64",
            "description": "Sum of the sample counts the batches declared.",
            "examples": [
              2391044
            ]
          }
        }
      },
      "CatalogType": {
        "type": "object",
        "properties": {
          "identifier": {
            "type": "string",
            "description": "HealthKit identifier.",
            "examples": [
              "HKQuantityTypeIdentifierStepCount"
            ]
          },
          "kind": {
            "type": "string",
            "description": "`quantity`, `category`, `workout`, `heartbeatSeries`, `ecg`, `stateOfMind`, `medicationDose` or `activitySummary`.",
            "examples": [
              "quantity"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Canonical unit every value of the type is stored in; null for types without one.",
            "examples": [
              "count"
            ]
          },
          "rows": {
            "type": "integer",
            "format": "int64",
            "description": "`rawRows` plus `aggregateRows`.",
            "examples": [
              184211
            ]
          },
          "rawRows": {
            "type": "integer",
            "format": "int64",
            "description": "Individual samples stored.",
            "examples": [
              171032
            ]
          },
          "aggregateRows": {
            "type": "integer",
            "format": "int64",
            "description": "Aggregate buckets the phone computed (what daily metrics are built from).",
            "examples": [
              13179
            ]
          },
          "earliest": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Start of the oldest row. Epoch milliseconds (UTC)."
          },
          "latest": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Start of the newest row. Epoch milliseconds (UTC)."
          }
        },
        "description": "One HealthKit type that holds data for this user."
      },
      "LatestMetric": {
        "type": "object",
        "properties": {
          "identifier": {
            "type": "string",
            "description": "HealthKit identifier.",
            "examples": [
              "HKQuantityTypeIdentifierRestingHeartRate"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Canonical unit of value.",
            "examples": [
              "count/min"
            ]
          },
          "value": {
            "type": [
              "number",
              "null"
            ],
            "description": "The reading.",
            "examples": [
              54
            ]
          },
          "timestamp": {
            "type": "integer",
            "format": "int64",
            "description": "Start of the sample. Epoch milliseconds (UTC)."
          }
        },
        "description": "The newest raw sample of one quantity type."
      },
      "DailyMetric": {
        "type": "object",
        "description": "One type's local-day series, deduplicated across devices. A page boundary can fall inside days, so the same identifier may open the next page; append its days.",
        "properties": {
          "identifier": {
            "type": "string",
            "description": "HealthKit identifier.",
            "examples": [
              "HKQuantityTypeIdentifierStepCount"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Canonical unit of every value.",
            "examples": [
              "count"
            ]
          },
          "days": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date",
                  "description": "Local calendar day (`PULS_TIME_ZONE`)."
                },
                "value": {
                  "type": [
                    "number",
                    "null"
                  ],
                  "description": "The day's total for a cumulative type (steps), its mean for a discrete one (heart rate).",
                  "examples": [
                    8412
                  ]
                }
              }
            },
            "description": "Days with a value, ascending."
          }
        }
      },
      "ActivityDay": {
        "type": "object",
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "Local calendar day."
          },
          "moveKcal": {
            "type": [
              "number",
              "null"
            ],
            "description": "Active energy burned, kcal.",
            "examples": [
              512.4
            ]
          },
          "moveGoalKcal": {
            "type": [
              "number",
              "null"
            ],
            "description": "Move goal, kcal.",
            "examples": [
              600
            ]
          },
          "exerciseMin": {
            "type": [
              "number",
              "null"
            ],
            "description": "Exercise minutes.",
            "examples": [
              34
            ]
          },
          "exerciseGoalMin": {
            "type": [
              "number",
              "null"
            ],
            "description": "Exercise goal, minutes.",
            "examples": [
              30
            ]
          },
          "standHours": {
            "type": [
              "number",
              "null"
            ],
            "description": "Hours with a stand.",
            "examples": [
              11
            ]
          },
          "standGoalHours": {
            "type": [
              "number",
              "null"
            ],
            "description": "Stand goal, hours.",
            "examples": [
              12
            ]
          },
          "moveMode": {
            "type": [
              "integer",
              "null"
            ],
            "description": "HealthKit move mode: 1 active energy, 2 move time.",
            "examples": [
              1
            ]
          },
          "moveTimeMin": {
            "type": [
              "number",
              "null"
            ],
            "description": "Move minutes (move-time mode only).",
            "examples": [
              null
            ]
          },
          "moveTimeGoalMin": {
            "type": [
              "number",
              "null"
            ],
            "description": "Move goal, minutes (move-time mode only).",
            "examples": [
              null
            ]
          }
        },
        "description": "One day of Activity rings. A ring the device does not track is null."
      },
      "WorkoutSummary": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid",
            "description": "HealthKit UUID."
          },
          "activityType": {
            "type": "string",
            "description": "HealthKit activity type name.",
            "examples": [
              "running"
            ]
          },
          "start": {
            "type": "integer",
            "format": "int64",
            "description": "Start. Epoch milliseconds (UTC)."
          },
          "end": {
            "type": "integer",
            "format": "int64",
            "description": "End. Epoch milliseconds (UTC)."
          },
          "durationS": {
            "type": [
              "number",
              "null"
            ],
            "description": "Duration, seconds.",
            "examples": [
              2712.5
            ]
          },
          "distanceM": {
            "type": [
              "number",
              "null"
            ],
            "description": "Total distance, metres; null when none was recorded.",
            "examples": [
              8046.7
            ]
          },
          "energyKcal": {
            "type": [
              "number",
              "null"
            ],
            "description": "Active energy, kcal.",
            "examples": [
              612.3
            ]
          },
          "hasRoute": {
            "type": "boolean",
            "description": "Whether GPS route points were synced.",
            "examples": [
              true
            ]
          },
          "availableMetrics": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "HealthKit identifiers with a stream in `GET /v1/workouts/{uuid}/series`.",
            "examples": [
              [
                "HKQuantityTypeIdentifierHeartRate",
                "HKQuantityTypeIdentifierRunningPower"
              ]
            ]
          }
        },
        "description": "One workout, without its streams or events."
      },
      "WorkoutDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/WorkoutSummary"
          },
          {
            "type": "object",
            "properties": {
              "statisticsDetail": {
                "type": "object",
                "additionalProperties": {
                  "type": "object",
                  "properties": {
                    "min": {
                      "type": "number"
                    },
                    "avg": {
                      "type": "number"
                    },
                    "max": {
                      "type": "number"
                    },
                    "sum": {
                      "type": "number"
                    }
                  }
                },
                "description": "Per-type statistics HealthKit computed for the workout, keyed by HealthKit identifier, in each type's canonical unit.",
                "examples": [
                  {
                    "HKQuantityTypeIdentifierHeartRate": {
                      "min": 92,
                      "avg": 151.6,
                      "max": 178
                    },
                    "HKQuantityTypeIdentifierActiveEnergyBurned": {
                      "sum": 612.3
                    }
                  }
                ]
              },
              "events": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                },
                "description": "Workout events (pauses, laps, segments) as HealthKit reported them.",
                "examples": [
                  [
                    {
                      "type": "pause",
                      "start": 1767226800000,
                      "end": 1767226860000
                    }
                  ]
                ]
              },
              "activities": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                },
                "description": "Sub-activities of a multisport workout.",
                "examples": [
                  []
                ]
              }
            }
          }
        ]
      },
      "SleepStages": {
        "type": "object",
        "description": "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.",
        "properties": {
          "core": {
            "type": "number",
            "examples": [
              238
            ]
          },
          "deep": {
            "type": "number",
            "examples": [
              71.5
            ]
          },
          "rem": {
            "type": "number",
            "examples": [
              122
            ]
          },
          "unspecified": {
            "type": "number",
            "examples": [
              0
            ]
          },
          "awake": {
            "type": "number",
            "examples": [
              18
            ]
          }
        }
      },
      "SleepNight": {
        "type": "object",
        "description": "One sleep session, attributed to the local calendar day it ended on (the wake-up day). Overlapping sources are never summed: `inBedMinutes` is the highest single-source total and `asleepMinutes` plus stages come from the source with the most sleep.",
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "Local wake-up day."
          },
          "start": {
            "type": "integer",
            "format": "int64",
            "description": "First sample of the session, any source. Epoch milliseconds (UTC).",
            "examples": [
              1767221400000
            ]
          },
          "end": {
            "type": "integer",
            "format": "int64",
            "description": "Last sample of the session, any source. Epoch milliseconds (UTC).",
            "examples": [
              1767249300000
            ]
          },
          "inBedMinutes": {
            "type": "number",
            "description": "Highest single-source in-bed total, minutes.",
            "examples": [
              462
            ]
          },
          "asleepMinutes": {
            "type": "number",
            "description": "Core + deep + REM + unspecified of the winning source, minutes.",
            "examples": [
              431.5
            ]
          },
          "stages": {
            "$ref": "#/components/schemas/SleepStages"
          },
          "sources": {
            "type": "integer",
            "description": "Distinct sources that contributed samples to this session.",
            "examples": [
              2
            ]
          }
        }
      },
      "Sample": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid",
            "description": "HealthKit UUID."
          },
          "start": {
            "type": "integer",
            "format": "int64",
            "description": "Start. Epoch milliseconds (UTC)."
          },
          "end": {
            "type": "integer",
            "format": "int64",
            "description": "End. Epoch milliseconds (UTC)."
          },
          "value": {
            "type": [
              "number",
              "null"
            ],
            "description": "Quantity value in the page's canonical unit, or the category type's integer enum value.",
            "examples": [
              72
            ]
          },
          "label": {
            "type": [
              "string",
              "null"
            ],
            "description": "Category types only: the HealthKit name of value.",
            "examples": [
              null
            ]
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Name of the app or device that recorded it.",
            "examples": [
              "Apple Watch"
            ]
          }
        }
      },
      "SamplesPage": {
        "type": "object",
        "description": "Raw HealthKit samples of one type, ordered by start time. Not deduplicated across devices.",
        "properties": {
          "type": {
            "type": "string",
            "description": "The requested HealthKit identifier.",
            "examples": [
              "HKQuantityTypeIdentifierHeartRate"
            ]
          },
          "kind": {
            "type": "string",
            "enum": [
              "quantity",
              "category"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Canonical unit of every quantity value; null for category types.",
            "examples": [
              "count/min"
            ]
          },
          "samples": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Sample"
            }
          },
          "nextOffset": {
            "type": "integer",
            "description": "Offset to pass for the next page; a short page means the end.",
            "examples": [
              1000
            ]
          }
        }
      },
      "WorkoutSeries": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "description": "HealthKit identifier of the stream.",
            "examples": [
              "HKQuantityTypeIdentifierHeartRate"
            ]
          },
          "unit": {
            "type": [
              "string",
              "null"
            ],
            "description": "Canonical unit of every value.",
            "examples": [
              "count/min"
            ]
          },
          "totalPoints": {
            "type": "integer",
            "description": "Points recorded before downsampling.",
            "examples": [
              2713
            ]
          },
          "points": {
            "type": "array",
            "description": "[epoch milliseconds, value] pairs, ordered by time.",
            "items": {
              "type": "array",
              "prefixItems": [
                {
                  "type": "integer",
                  "format": "int64"
                },
                {
                  "type": "number"
                }
              ],
              "minItems": 2,
              "maxItems": 2
            }
          }
        }
      },
      "WorkoutSeriesResponse": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "start": {
            "type": "integer",
            "format": "int64",
            "description": "Workout start. Epoch milliseconds (UTC)."
          },
          "end": {
            "type": "integer",
            "format": "int64",
            "description": "Workout end. Epoch milliseconds (UTC)."
          },
          "maxPoints": {
            "type": "integer",
            "description": "The point cap applied to each stream.",
            "examples": [
              500
            ]
          },
          "series": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WorkoutSeries"
            }
          }
        }
      },
      "StateOfMindEntry": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Local calendar day of the entry."
          },
          "timestamp": {
            "type": "integer",
            "format": "int64",
            "description": "When it was logged. Epoch milliseconds (UTC)."
          },
          "kind": {
            "type": "string",
            "description": "momentaryEmotion or dailyMood.",
            "examples": [
              "momentaryEmotion"
            ]
          },
          "valence": {
            "type": [
              "number",
              "null"
            ],
            "description": "-1 (very unpleasant) to +1 (very pleasant).",
            "examples": [
              0.42
            ]
          },
          "valenceClassification": {
            "type": [
              "string",
              "null"
            ],
            "description": "Apple's band for valence, e.g. slightlyPleasant.",
            "examples": [
              "slightlyPleasant"
            ]
          },
          "labels": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Feelings picked, e.g. calm, stressed.",
            "examples": [
              [
                "calm",
                "content"
              ]
            ]
          },
          "associations": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What they are about, e.g. work, family.",
            "examples": [
              [
                "work"
              ]
            ]
          }
        }
      },
      "SummaryStat": {
        "type": "object",
        "description": "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.",
        "properties": {
          "unit": {
            "type": "string"
          },
          "days": {
            "type": "integer"
          },
          "mean": {
            "type": "number"
          },
          "min": {
            "type": "number"
          },
          "max": {
            "type": "number"
          },
          "total": {
            "type": "number"
          },
          "source": {
            "type": "string",
            "enum": [
              "metric_daily",
              "activity_summaries"
            ]
          }
        }
      },
      "SummaryReading": {
        "type": "object",
        "description": "The newest raw sample of a body metric, whenever it was taken.",
        "properties": {
          "value": {
            "type": "number"
          },
          "unit": {
            "type": "string"
          },
          "timestamp": {
            "type": "integer",
            "format": "int64",
            "description": "When the reading was taken. Epoch milliseconds (UTC)."
          }
        }
      },
      "Summary": {
        "type": "object",
        "description": "GET `/v1/summary`?`format=json`: the numbers behind the markdown page. A section is absent when the range holds no data for it.",
        "properties": {
          "userID": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "range": {
            "type": "string",
            "enum": [
              "7d",
              "14d",
              "30d",
              "90d"
            ]
          },
          "days": {
            "type": "integer"
          },
          "startDate": {
            "type": "string",
            "format": "date",
            "description": "First local calendar day covered."
          },
          "endDate": {
            "type": "string",
            "format": "date",
            "description": "Last local calendar day covered: today in timeZone."
          },
          "generatedAt": {
            "type": "integer",
            "format": "int64",
            "description": "When the summary was computed. Epoch milliseconds (UTC)."
          },
          "timeZone": {
            "type": "string",
            "description": "The IANA zone (`PULS_TIME_ZONE`) whose calendar cut the days."
          },
          "activity": {
            "type": "object",
            "properties": {
              "steps": {
                "$ref": "#/components/schemas/SummaryStat"
              },
              "activeEnergy": {
                "$ref": "#/components/schemas/SummaryStat"
              },
              "exercise": {
                "$ref": "#/components/schemas/SummaryStat"
              },
              "stand": {
                "$ref": "#/components/schemas/SummaryStat"
              }
            }
          },
          "heart": {
            "type": "object",
            "properties": {
              "restingHeartRate": {
                "$ref": "#/components/schemas/SummaryStat"
              },
              "hrvSDNN": {
                "$ref": "#/components/schemas/SummaryStat"
              }
            }
          },
          "sleep": {
            "type": "object",
            "description": "The longest sleep session of each wake-up day in the range.",
            "properties": {
              "nights": {
                "type": "integer"
              },
              "meanAsleepMinutes": {
                "type": "number"
              },
              "minAsleepMinutes": {
                "type": "number"
              },
              "maxAsleepMinutes": {
                "type": "number"
              }
            }
          },
          "workouts": {
            "type": "object",
            "properties": {
              "count": {
                "type": "integer"
              },
              "totalMinutes": {
                "type": "number"
              },
              "totalDistanceM": {
                "type": "number",
                "description": "Absent when no workout in the range recorded a distance."
              },
              "byActivityType": {
                "type": "array",
                "description": "Most frequent first, at most three.",
                "items": {
                  "type": "object",
                  "properties": {
                    "activityType": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "body": {
            "type": "object",
            "properties": {
              "weight": {
                "$ref": "#/components/schemas/SummaryReading"
              },
              "bodyFat": {
                "$ref": "#/components/schemas/SummaryReading"
              }
            }
          },
          "coverage": {
            "type": "object",
            "properties": {
              "lastSync": {
                "type": [
                  "integer",
                  "null"
                ],
                "format": "int64",
                "description": "Epoch milliseconds of the most recent upload; null when nothing has been uploaded."
              },
              "daysWithData": {
                "type": "integer",
                "description": "Days in the range on which at least one section has a value."
              }
            }
          }
        },
        "examples": [
          {
            "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
            }
          }
        ]
      },
      "Error": {
        "type": "object",
        "description": "Every non-2xx answer from a JSON route.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "What went wrong, in English. Meant for a person or a log, not for matching.",
            "examples": [
              "end must be after start"
            ]
          }
        }
      },
      "Index": {
        "type": "object",
        "description": "Where everything else is.",
        "properties": {
          "name": {
            "type": "string",
            "examples": [
              "PulsHealth Product API"
            ]
          },
          "version": {
            "type": "string",
            "examples": [
              "v1"
            ]
          },
          "docs": {
            "type": "string",
            "examples": [
              "/docs"
            ]
          },
          "openapi": {
            "type": "string",
            "examples": [
              "/openapi.json"
            ]
          },
          "health": {
            "type": "string",
            "examples": [
              "/healthz"
            ]
          },
          "auth": {
            "type": "string",
            "examples": [
              "Authorization: Bearer $PULS_API_TOKEN"
            ]
          },
          "user": {
            "type": "string",
            "examples": [
              "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": {
            "type": "string",
            "examples": [
              "Read-only API for downstream products that use PulsHealth data."
            ]
          }
        }
      },
      "Health": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "db": {
            "type": "boolean",
            "description": "Whether the database answered a ping in the last two seconds."
          }
        }
      },
      "UsersResponse": {
        "type": "object",
        "properties": {
          "users": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/User"
            }
          },
          "default": {
            "type": "string",
            "format": "uuid",
            "description": "The user served when a request names none (`PULS_USER_ID`)."
          },
          "multiUser": {
            "type": "boolean",
            "description": "Whether `user=` may name anyone else (`PULS_MULTI_USER`).",
            "examples": [
              false
            ]
          },
          "timeZone": {
            "type": "string",
            "description": "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.",
            "examples": [
              "Europe/Berlin"
            ]
          }
        }
      }
    }
  }
}
