All endpoints are served by the FastAPI container (pulsebase-api) behind a reverse proxy.
Base URL: https://your-domain.com (set via APP_BASE_URL in env/.env.api)
Session-based via signed cookie (SessionMiddleware). Login at POST /login sets
user_id in the session. All protected routes check the session and redirect to /login
if missing.
The JSON API endpoints (/api/*) also require a valid session — they return a redirect
to /login if called without one (not a 401 JSON response).
Sessions expire after 1 hour of inactivity (max_age=3600, ASVS V8.2.2).
All JSON error responses use a single envelope:
{ "error": { "code": "VALIDATION_ERROR", "message": "Eingabe ungültig", "details": [ … ] } }code is a stable machine-readable string, message a human-readable summary, and the
optional details array is only present for validation errors. Two global exception
handlers in api/src/main.py normalise everything into this shape:
| Status | code |
Source |
|---|---|---|
| 400 | BAD_REQUEST |
raised HTTPException(400) |
| 403 | FORBIDDEN |
CSRF check (/garmin/unlink, /libre/unlink, POST /account/delete/confirm/{token}) or epilepsy-mode guard on /api/seizures* |
| 404 | NOT_FOUND |
unknown route or missing resource |
| 405 | METHOD_NOT_ALLOWED |
wrong HTTP verb |
| 422 | VALIDATION_ERROR |
Pydantic request validation |
| 429 | RATE_LIMITED |
rate limiter |
Security note (WS-A / NEU-1): the 422 handler maps only loc → field and msg; it
deliberately drops Pydantic's input/ctx, which would otherwise echo the client's
submitted value (e.g. a plaintext password on /login, /register, /auth/reset/*) into
both the response body and the Sentry event. No client-submitted value ever leaves the
server via an error response.
Exceptions to the envelope: GET /health and GET /ready keep the probe convention
{ "status": … } (Kubernetes-style liveness/readiness, polled by UptimeRobot) — these are
not part of the error envelope.
Renders the login form.
Rate-limited to 10 requests/minute per IP.
| Field | Type | Required |
|---|---|---|
email |
string | yes |
password |
string | yes |
A valid csrf_token form field is also required; on mismatch the login form is
re-rendered with an error (HTTP 400).
On success: redirects to /. Failed-attempt counter resets to 0.
On failure (unverified email): re-renders login form with error + link to /auth/resend-verify (HTTP 400).
On failure (wrong credentials / locked): re-renders login form with error message (HTTP 400).
Account lockout: After 5 consecutive failed attempts the account is locked for 15 minutes. While locked, all login attempts return 400 with a "Account gesperrt" message regardless of password. A notification email is sent to the user's address at the moment of lockout. The lock expires automatically; no admin action needed.
Renders the registration form.
Rate-limited to 5 requests/minute per IP.
| Field | Type | Required | Validation |
|---|---|---|---|
name |
string | yes | 1–100 characters |
email |
string | yes | must be unique; normalized to lowercase + trimmed |
password |
string | yes | min 12 characters (ASVS V2.1.1 / NIST SP 800-63B) |
password_confirm |
string | yes | must match password |
consent_health |
checkbox | yes | explicit consent for health data processing (DSGVO Art. 9) |
consent_terms |
checkbox | yes | acceptance of Terms of Service |
consent_age |
checkbox | yes | age self-declaration (≥ 16 years, DSGVO Art. 8) |
On success: creates account, logs all three consents (health_data, terms, age_16plus) to user_consents, sends verification email, redirects to /login?verify=sent.
If email send fails: redirects to /login?verify=failed (amber banner with resend link).
On failure: re-renders form with error message (HTTP 400).
Login is blocked until the email address is verified (see GET /auth/verify/{token}).
Renders the privacy policy (privacy.html). No session required.
Renders the terms of service (terms.html). No session required.
Renders the legal imprint (imprint.html). No session required.
Renders the accessibility statement (accessibility.html). No session required.
Renders a form to request a new verification email.
Rate-limited to 3 requests/hour per IP.
| Field | Type | Required |
|---|---|---|
email |
string | yes |
Always returns HTTP 200 (non-leaking — same response whether the email exists or not).
If the email is registered and not yet verified, a new single-use verification link is sent.
If sending fails (e.g. RESEND_API_KEY not configured), the form is re-rendered with a
warning; the link is not logged.
Completes email verification. Token is a random single-use value (only its SHA-256 hash is stored server-side), 24h TTL.
Returns HTTP 400 if the token is invalid or expired.
On success: sets email_verified_at and redirects to /login?verified=1.
Renders the password reset request form.
Rate-limited to 3 requests/hour per IP.
| Field | Type | Required |
|---|---|---|
email |
string | yes |
Always returns HTTP 200 with a confirmation message (non-leaking — same response whether the email exists or not). If the email is registered, a single-use reset link is sent via Resend (15-minute TTL). The link is never logged to stdout.
Renders the new password form. Token is a random single-use value (only its SHA-256 hash is stored server-side), 15-minute TTL.
Returns HTTP 400 if the token is invalid or expired.
Rate-limited to 5 requests/hour per IP.
| Field | Type | Required | Validation |
|---|---|---|---|
password |
string | yes | min 12 characters |
password_confirm |
string | yes | must match password |
On success: updates the password and redirects to /login?reset=1.
On failure: re-renders form with error message (HTTP 400).
Redirects to /dashboard.
Requires a valid csrf_token form field. Returns HTTP 403 (re-renders the login form
with an error) if the token is missing or does not match the session.
On success: clears the session and redirects to /login.
Renders the Garmin account linking form.
Rate-limited to 5 requests/hour per IP. Requires a valid csrf_token form field
(re-renders the form with an error, HTTP 403, on mismatch).
| Field | Type | Notes |
|---|---|---|
garmin_email |
string | Garmin Connect email |
garmin_password |
string | Used once, then deleted from memory |
Authenticates against Garmin Connect via a temporary directory (tempfile.TemporaryDirectory), Fernet-encrypts the session token, and stores it in the user_tokens DB table. Marks user as garmin_linked = true, then queues an initial sync. No token is written permanently to disk.
On success: redirects to /?linked=1.
On failure: re-renders form with error (HTTP 400).
Sets garmin_linked = false, clears garmin_email, and deletes the token row from user_tokens. Redirects to /.
Renders dashboard.html. The page loads data asynchronously via the /api/* endpoints
below using fetch().
Renders the activity detail page (activity.html) for a single activity.
Includes GPS track (Leaflet.js), HR/pace/elevation/cadence charts, stat grid, and
training effect bars. Redirects to /dashboard if the activity does not exist or
belongs to a different user.
Renders the settings page. Shows account info (name, email), Garmin Connect connection status, LibreLinkUp connection status, and the Epilepsie-Modus toggle — each with inline connect/disconnect buttons.
Renders the seizure diary page (epilepsy.html). Redirects to /settings if
epilepsy_mode is not enabled for the user. Contains three sections:
daily risk indicator, log form, and event history.
Renders the LibreLinkUp linking form. When already linked, shows the connected email and a disconnect button instead of the form.
Rate-limited to 5 requests/hour per IP. Requires a valid csrf_token form field
(re-renders the form with an error, HTTP 403, on mismatch).
| Field | Type | Notes |
|---|---|---|
libre_email |
string | LibreLinkUp account email |
libre_password |
string | Used once for initial auth, then deleted from memory |
Authenticates against the LibreLinkUp EU endpoint via a temporary directory (tempfile.TemporaryDirectory), Fernet-encrypts the session token, and stores it in the user_tokens DB table. Marks user as libre_linked = true. No token is written permanently to disk.
Prerequisite: the sensor owner must have accepted the user as a follower in their LibreLink app before linking will succeed.
On success: redirects to /dashboard.
On failure: re-renders form with error message (HTTP 400).
Disconnects LibreLinkUp: sets libre_linked = false, clears libre_email,
deletes all glucose_readings rows for this user (irreversible), and
removes the token file. Redirects to /libre/link.
Permanent redirect (HTTP 301) to /metrics/hr-zscore.
Permanent redirect (HTTP 301) to /metrics/readiness-rf.
Permanent redirect (HTTP 301) to /metrics/correlations.
Permanent redirect (HTTP 301) to /metrics/battery-pattern.
Rate-limited to 3 requests/hour per IP (ASVS V4.1.1).
Re-authentication required before deletion (DSGVO Art. 17 / ASVS V2.4.1):
| Field | Type | Required |
|---|---|---|
email |
string | yes — must match the account's email |
password |
string | yes — current account password |
A valid csrf_token form field is also required (HTTP 400 on mismatch).
This is the first step of a two-step deletion. On success it does not delete
immediately: it marks the account as pending-deletion, sends a confirmation email
containing a single-use link (/account/delete/confirm/{token}), and renders the
account_delete_pending.html page. If the email cannot be sent, the event is logged.
The actual deletion happens at POST /account/delete/confirm/{token} below.
On failure (wrong email or password): re-renders settings page with error (HTTP 400).
Rate-limited to 10 requests/hour per IP.
Renders the deletion confirmation form (account_delete_confirm.html) — no side effects,
so an email scanner auto-clicking the link cannot delete the account. The token is a random
single-use value (only its SHA-256 hash is stored server-side), 1-hour TTL.
Returns HTTP 400 (renders account_delete_confirm.html with an error) if the token is
invalid or expired.
Rate-limited to 10 requests/hour per IP.
Performs the deletion after the user explicitly confirms via the form. Requires a valid
csrf_token form field (HTTP 403 on mismatch). On a valid token, deletes the account and
all associated data (activities, sleep, HRV, glucose, seizure events, daily summaries,
ML predictions, Garmin tokens), clears the session, and redirects to /login?deleted=1.
Returns HTTP 400 (renders account_delete_confirm.html with an error) if the token is
invalid or expired. Redirects to /login if the user no longer exists.
Rate-limited to 10 requests/hour per IP (ASVS V4.1.1 / DSGVO Art. 20).
Returns a JSON file containing all personal data for the authenticated user.
Response headers:
Content-Type: application/jsonContent-Disposition: attachment; filename=pulsebase-export.json
Response body:
{
"exported_at": "2026-05-22T14:00:00+00:00",
"schema_version": "1.0",
"user": { "id": 1, "name": "...", "email": "...", "created_at": "...", ... },
"activities": [...],
"sleep_sessions": [...],
"hrv_daily": [...],
"daily_summary": [...],
"seizure_events": [...],
"glucose_readings": [...]
}Sensitive fields excluded from export: password_hash, failed_login_attempts,
locked_until (ASVS V8.3.4).
All endpoints return application/json. User data is always scoped to the
authenticated user — cross-user access is not possible.
Returns activities within the requested time range.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
days |
7 |
1–365 | How many days back to query |
limit |
500 |
1–500 | Maximum number of results |
end_date |
today |
ISO date | Last day of the window (for time navigation) |
Response — array of objects ordered by started_at DESC:
[
{
"id": 42,
"sport_type": "running",
"started_at": "2026-04-27T06:32:00+00:00",
"duration_seconds": 3420,
"distance_meters": 10250.5,
"avg_hr": 148,
"calories": 512
}
]| Field | Type | Notes |
|---|---|---|
id |
integer | DB primary key — use for /api/activities/{id} |
sport_type |
string | e.g. running, cycling, swimming, strength_training |
started_at |
ISO 8601 datetime | UTC |
duration_seconds |
integer | null if unknown |
distance_meters |
float | null if unknown |
avg_hr |
integer | null if no HR data |
calories |
integer | null if unknown |
Returns full detail for a single activity including per-second records.
Response:
{
"id": 42,
"sport_type": "running",
"started_at": "2026-04-27T06:32:00+00:00",
"duration_seconds": 3420,
"distance_meters": 10250.5,
"calories": 512,
"avg_hr": 148,
"max_hr": 178,
"avg_pace_sec_per_km": 334.5,
"avg_speed_kmh": null,
"avg_cadence": 172,
"avg_power": null,
"elevation_gain": 42.0,
"aerobic_effect": 3.8,
"anaerobic_effect": 1.2,
"training_status": "PRODUCTIVE",
"records": [
{
"time": "2026-04-27T06:32:01+00:00",
"heart_rate": 142,
"pace_sec_per_km": 340.0,
"cadence": 170,
"power": null,
"elevation": 245.2,
"distance": 5.1,
"lat": 47.0707,
"lng": 15.4395
}
]
}| Field | Notes |
|---|---|
aerobic_effect |
Garmin aerobic training effect 1.0–5.0, null if not available |
anaerobic_effect |
Garmin anaerobic training effect 1.0–5.0, null if not available |
training_status |
From daily_summary for the activity date (LEFT JOIN) |
records |
Per-second data points, empty array if no GPS/HR data stored |
Returns HTTP 404 with {"error": {"code": "NOT_FOUND", ...}} if activity does not exist
or belongs to a different user.
Returns daily summaries.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
days |
30 |
1–365 | How many days back to return |
end_date |
today |
ISO date | Last day of the window (for time navigation) |
Response — array of objects ordered by date ascending:
[
{
"date": "2026-04-27",
"steps": 8423,
"resting_hr": 52,
"avg_stress": 28,
"calories_total": 2180,
"intensity_moderate": 22,
"intensity_vigorous": 8,
"body_battery_high": 87,
"body_battery_low": 14
}
]Returns recent sleep sessions.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
days |
14 |
1–365 | How many days back to return |
end_date |
today |
ISO date | Last day of the window (for time navigation) |
Response — array ordered by start_time DESC:
[
{
"date": "2026-04-27",
"sleep_score": 78,
"total_sleep_seconds": 27540,
"deep_sleep_seconds": 5400,
"light_sleep_seconds": 14400,
"rem_sleep_seconds": 6300,
"awake_seconds": 1440
}
]Returns the most recent HRV entry.
Response — single object or null if no data:
{
"hrv_last_night": 48,
"hrv_weekly_avg": 52,
"hrv_status": "balanced"
}| Field | Notes |
|---|---|
hrv_last_night |
ms, last night measurement |
hrv_weekly_avg |
ms, 7-day rolling average |
hrv_status |
balanced / unbalanced / poor |
Returns HRV data points for a date range.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
days |
30 |
1–365 | How many days back |
end_date |
today |
ISO date | Last day of the window (for time navigation) |
Response — array ordered by date ascending:
[
{
"date": "2026-04-27",
"hrv_last_night": 48,
"hrv_weekly_avg": 52,
"hrv_status": "balanced"
}
]Returns weekly training volume aggregates.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
weeks |
12 |
1–56 | How many weeks back |
end_date |
today |
ISO date | Last day of the window (for time navigation) |
Response — array ordered by week ascending:
[
{
"week": "2026-04-27",
"activity_count": 4,
"total_km": 42.3,
"total_hours": 4.2,
"run_km": 35.1,
"ride_km": 7.2,
"other_hours": 1.0
}
]| Field | Notes |
|---|---|
week |
Monday of the week (ISO date) |
run_km |
Distance for running, trail_running, hiking, walking |
ride_km |
Distance for cycling, indoor_cycling |
other_hours |
Duration for all other sport types (strength, yoga, …) |
Returns a recovery score (0–100) computed from the autonomic and cognitive energy dimensions. Training-stress balance (physical/TSB) is deliberately excluded — it measures accumulated weekly load, not today's recovery quality.
Response:
{
"score": 74,
"label": "In Ordnung",
"cls": "badge-balanced",
"energy_autonomic": 78,
"energy_cognitive": 85
}| Field | Notes |
|---|---|
score |
0–100, weighted average of the two energy dimensions |
label |
Gut erholt (≥75) / In Ordnung (≥55) / Erholen (≥35) / Erschöpft (<35) |
cls |
CSS badge class for color coding (badge-balanced / badge-unbalanced / badge-poor) |
energy_autonomic |
Autonomic (HRV) energy score or null |
energy_cognitive |
Cognitive (sleep debt) energy score or null |
When neither dimension has data, the response is {"score": null, "label": "Keine Daten", "cls": "badge-poor", "energy_autonomic": null, "energy_cognitive": null}.
Score formula (a missing dimension is excluded and the weights renormalized):
| Dimension | Weight |
|---|---|
| Autonomic energy (HRV σ-baseline) | 60% |
| Cognitive energy (sleep debt) | 40% |
Returns the most recent training status entry.
Response — single object or null if no data:
{
"date": "2026-04-27",
"training_status": "PRODUCTIVE"
}training_status value |
Meaning |
|---|---|
PRODUCTIVE |
Load is building fitness |
MAINTAINING |
Fitness is being maintained |
RECOVERY |
Body is recovering |
UNPRODUCTIVE |
Load not resulting in adaptation |
OVERREACHING |
Training load too high |
DETRAINING |
Fitness declining |
Returns recent glucose readings from LibreLinkUp. Only available when libre_linked = true.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
hours |
24 |
1–168 | How many hours back to return |
Response — array ordered by time DESC:
[
{
"time": "2026-05-08T14:32:00+00:00",
"value_mgdl": 98.0,
"trend": 3,
"is_high": false,
"is_low": false
}
]trend value |
Meaning |
|---|---|
1 |
Falling quickly (↓↓) |
2 |
Falling (↓) |
3 |
Stable (→) |
4 |
Rising (↑) |
5 |
Rising quickly (↑↑) |
Returns aggregated glucose statistics. Only available when libre_linked = true.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
days |
14 |
1–90 | How many days to include |
Response:
{
"avg_mgdl": 103.4,
"min_mgdl": 72.0,
"max_mgdl": 168.0,
"tir_pct": 87.2,
"count_high": 12,
"count_low": 1
}| Field | Notes |
|---|---|
tir_pct |
Time-in-Range percentage (70–180 mg/dL) |
count_high |
Readings flagged as high by the sensor |
count_low |
Readings flagged as low (hypoglycemia) by the sensor |
Returns historical ML prediction values grouped by model.
Query parameters:
| Parameter | Default | Notes |
|---|---|---|
days |
30 |
How many days back |
end_date |
today |
Last day of the window (ISO date, for time navigation) |
Response — object with one array per model:
{
"anomaly_hr": [
{ "date": "2026-05-08", "value": 1.29, "is_anomaly": false, "z_score": 1.29 }
],
"readiness_rf": [
{ "date": "2026-05-08", "value": 74.0 }
],
"correlation_sleep_hrv": [
{ "date": "2026-05-08", "value": 0.61, "r": 0.61, "n": 42 }
]
}Returns the latest ML model outputs for the authenticated user. All models write daily
to ml_predictions; this endpoint returns the most recent row per model (within 1 day).
Response — object with one key per available model (absent if no data):
{
"anomaly_hr": {
"value": 1.29,
"is_anomaly": false,
"baseline_mean": 43.5,
"baseline_std": 3.2,
"threshold": 1.5
},
"readiness_rf": {
"value": 71.0
},
"correlation_sleep_hrv": {
"value": 0.61,
"r": 0.61,
"p_value": 0.003,
"n_samples": 42,
"interpretation": "mittel"
}
}| Model key | value |
Notes |
|---|---|---|
anomaly_hr |
Z-score of today's resting HR | is_anomaly: true when z > 1.5 |
readiness_rf |
Predicted readiness score 0–100 | null when < 30 valid training rows |
correlation_sleep_hrv |
Pearson r (−1 to 1) | Requires ≥ 10 sleep→HRV pairs |
acwr |
Acute-to-Chronic Workload Ratio | level: "green"|"amber"|"red" based on ATL/CTL |
training_monotony |
Training variation score | strain: combined workload×monotony metric |
spo2_trend |
Mean SpO₂ (7d) | slope, trend, apnea_flag if ≥2 nights <90% |
sleep_consistency |
Sleep-wake timing regularity 0–100 | std_wake_h, std_sleep_h: hours variance |
sleep_score_custom |
Custom sleep quality 0–100 | Replaces Garmin sleep_score on dashboard |
hrv_status_custom |
HRV balance status | "BALANCED", "UNBALANCED", "LOW", "POOR" |
intensity_minutes_custom |
Karvonen intensity minutes | moderate_minutes, vigorous_minutes |
training_effect_custom |
Banister training effect 0–5 | trimp_today, ctl, vo2max estimate |
body_battery_custom |
Fresh-State energy score 5–100 | sleep_quality, hrv_factor, activity_drain, stress_drain, sleep_h, deep_h, rem_h, prev_score |
stress_score_custom |
Stress index 0–100 | Blends HRV deviation (60%) + Garmin avg_stress (40%); low = relaxed |
running_economy |
Running efficiency score 0–100 | avg_gct_ms, avg_vo_mm, avg_vr_pct; running only; null if no runs |
hrv_recovery |
HRV recovery speed post-training | recovery_speed (ms/day), n_events, hrv_baseline, trimp_threshold |
In addition to the per-model keys, the response always carries two top-level fields:
min_samples_met (boolean — true once ≥ 30 valid training rows exist for the RF model)
and n_samples (integer — number of training rows used). When no per-model ML data has
been computed yet, only these two fields are present.
Returns today's three energy dimension scores computed by the ML service.
Response — object with one key per computed dimension (absent if no data yet):
{
"energy_physical": {
"score": 54.0,
"atl": 28.3,
"ctl": 31.1,
"tsb": 2.8,
"hrmax": 185.0
},
"energy_autonomic": {
"score": 62.0,
"deviation": 0.8,
"baseline_mean": 3.81,
"baseline_std": 0.22,
"hrv_7d_mean": 3.99
},
"energy_cognitive": {
"score": 78.0,
"debt_hours": 3.7,
"days_used": 7
}
}| Field | Notes |
|---|---|
energy_physical.tsb |
Training Stress Balance (positive = recovered, negative = fatigued) |
energy_autonomic.deviation |
HRV deviation in σ units from 90-day baseline |
energy_cognitive.debt_hours |
Cumulative 7-day sleep deficit vs 7h target |
Returns {} if ML inference has not run yet.
Returns the computed training-load summary (Banister TRIMP / CTL / TSB) for the user.
Query parameters:
| Parameter | Default | Range | Notes |
|---|---|---|---|
lookback_days |
TRIMP_LOOKBACK_DAYS (env) |
1–365 | How many days back to aggregate; falls back to the configured default when omitted |
Saves the user's date of birth, biological sex, and optional epilepsy / SpO₂ mode flags. Used for Banister TRIMP computation in the ML service. Session-protected.
Request body (JSON):
{ "date_of_birth": "1990-05-15", "sex": "m", "weight_kg": 72.5, "epilepsy_mode": true, "spo2_enabled": true }| Field | Type | Constraint |
|---|---|---|
date_of_birth |
date | null |
ISO 8601, must be in the past |
sex |
string | null |
"m", "f", or "diverse" |
weight_kg |
float | null |
30–300 |
epilepsy_mode |
boolean | null |
Enables seizure diary; omit to leave unchanged |
spo2_enabled |
boolean | null |
Enables SpO₂ tracking; omit to leave unchanged |
Response on success:
{ "ok": true }Error responses:
422—date_of_birthnot in the past, orsexnot in allowed values
Sets the subjective RPE (Rate of Perceived Exertion) for an activity. Session-protected.
Path parameter: activity_id — integer
Request body (JSON):
{ "rpe": 7 }| Field | Type | Constraint |
|---|---|---|
rpe |
integer | 1–10 (Foster CR-10 scale) |
Response on success:
{ "ok": true, "rpe": 7 }Error responses:
404— activity not found or belongs to another user422—rpeout of range
Returns the sync state for the authenticated user.
Response:
{
"pending": false,
"last_sync_at": "2026-05-15T06:12:44.123456+00:00"
}| Field | Notes |
|---|---|
pending |
true while sync is queued but not yet processed |
last_sync_at |
ISO 8601 timestamp or null if never synced |
Returns the ML inference state for the authenticated user.
Response:
{
"pending": false,
"last_ml_at": "2026-05-15T07:02:11.445312+00:00"
}| Field | Notes |
|---|---|
pending |
true while ML run is queued (set by sync-service after Garmin sync) |
last_ml_at |
ISO 8601 timestamp or null if never run |
Returns today's 👍/👎 feedback per model for the authenticated user.
Response — object mapping model key → boolean (helpful):
{ "readiness_rf": true, "anomaly_hr": false }Marks an ML prediction as helpful or not (upsert per day + model; stored in ml_feedback,
V25). Session-protected.
Request body (JSON):
{ "model": "readiness_rf", "helpful": true }| Field | Type | Constraint |
|---|---|---|
model |
string | must be one of the supported feedback models (FEEDBACK_MODELS) |
helpful |
boolean | true = 👍, false = 👎 |
Response on success:
{ "ok": true }Error responses:
422—modelnot in the supported set
Returns the evidence catalog for all metrics (the contents of
api/src/data/evidence_catalog.json). Session-protected.
Response — object keyed by metric, each entry carrying the evidence level, time horizon and EN 62366 fields:
{
"hrv": { "level": "...", "time_horizon": "...", "...": "..." }
}Renders the metrics overview page (metrics_overview.html) — all metrics as tiles with
their evidence badges. Session required.
Renders the help & methodology page (help.html, client-side searchable, deep-linkable
via /help#<metric-key>). Session required.
Renders the metric detail page for a named metric. Redirects to /dashboard if name is
not in the allowed set.
Valid name values:
steps, sleep, hrv, body-battery, body-battery-custom, physical,
autonomic, cognitive, hr-zscore, readiness-rf, hrv-status,
hrv-status-custom, training-status, readiness, sleep-score-custom,
stress-score-custom, intensity-minutes, training-effect, training-monotony,
spo2-trend, sleep-consistency, running-economy, hrv-recovery, recovery,
battery-pattern, correlations, vo2max
Response: HTML page (metrics.html template). Data is loaded client-side via
/api/activities, /api/daily, /api/sleep, /api/hrv/trend, and /api/energy.
Logs a new seizure event. Session-protected. Requires epilepsy_mode = true —
returns HTTP 403 ({"error": {"code": "FORBIDDEN", "message": "Epilepsy mode not enabled"}})
otherwise. The same epilepsy_mode guard (403) applies to every /api/seizures* endpoint below.
Request body (JSON):
{
"occurred_at": "2026-05-16T08:30:00Z",
"type": "focal",
"duration_seconds": 90,
"severity": 3,
"notes": "Nach Schlafentzug, mit Aura"
}| Field | Type | Constraint |
|---|---|---|
occurred_at |
ISO 8601 datetime | required |
type |
string | "focal", "generalized", or "unknown" (default) |
duration_seconds |
integer | null | optional |
severity |
integer | null | 1–5; optional |
notes |
string | null | free text; optional |
Response:
{ "ok": true, "id": 1 }Returns logged seizure events for the authenticated user.
Query parameters:
| Parameter | Default | Notes |
|---|---|---|
days |
365 |
Look-back window |
Response — array ordered by occurred_at DESC:
[
{
"id": 1,
"occurred_at": "2026-05-16T08:30:00+00:00",
"duration_seconds": 90,
"type": "focal",
"severity": 3,
"notes": "Nach Schlafentzug, mit Aura"
}
]Updates an existing seizure event. Session-protected. Accepts the same body and
validation as POST /api/seizures. The DB layer filters on id AND user_id, so a
user can only modify their own entries.
Response:
{ "ok": true, "id": 5 }Returns 404 with { "error": { "code": "NOT_FOUND", "message": "Seizure not found" } }
when the id does not exist or belongs to another user.
Deletes a seizure event. Session-protected and scoped to the authenticated user
(id AND user_id). The frontend shows a confirmation dialog before calling this.
Response:
{ "ok": true }Returns the same 404 shape as PATCH when the id is missing or not owned by the user.
Returns a rule-based daily risk indicator computed from existing biomarkers. No seizure history required — useful from day 1.
Response:
{
"level": "amber",
"flags": [
{ "label": "Schlafschuld", "detail": "3.2h in 7 Nächten", "color": "amber" }
],
"sleep_debt_h": 3.2
}| Field | Values | Notes |
|---|---|---|
level |
"ok" / "amber" / "red" |
Highest severity across all flags — "red" is never downgraded by subsequent amber flags |
flags |
array | Each active risk factor with label, detail, color |
sleep_debt_h |
float | Cumulative sleep deficit vs 7h/night target over last 7 nights |
Risk rules:
| Condition | Level | Data source |
|---|---|---|
| Sleep debt > 5h (last 7 nights vs 7h target) | red | sleep_sessions |
| Sleep debt 2–5h | amber | sleep_sessions |
| avg_stress (yesterday) > 70 | amber | daily_summary |
| HRV last night < 80 % of weekly average | amber | hrv_daily |
| Body battery daily low < 20 (yesterday) | amber | daily_summary |
| Vigorous intensity > 60 min (yesterday) | amber | daily_summary |
| Resting HR > 110 % of 30-day baseline | amber | daily_summary |
Disclaimer: These are rule-based heuristics. No prospective study has validated this exact combination of consumer wearable metrics as seizure predictors. The thresholds are clinically plausible but not clinically validated for epilepsy specifically. This indicator is not a substitute for neurological care.
Sleep deprivation is the most consistently reported seizure trigger in clinical epilepsy literature. The 7h/night target follows Walker (2017), NSF and AASM adult sleep recommendations. The mechanism: sleep loss reduces GABA-ergic inhibition and upregulates glutamate, progressively lowering cortical seizure threshold (Bazil 2003, Malow 2004). The 5h cumulative threshold corresponds to ≈ 43 min average nightly deficit — the point where the published literature describes measurable effects on cortical excitability.
- Sleep deprivation as trigger: Frucht et al. (2000) — 37% of patients report it as their primary trigger; Nakken et al. (2005) — confirmed in prospective diary study.
- Neuroscience: Sanchez-Alavez et al. (2019) — sleep–wake cycle regulates GABA/glutamate balance; disruption measurably shifts the excitation–inhibition ratio.
HRV (heart rate variability) reflects the balance between the sympathetic and parasympathetic nervous system. A significant acute drop indicates elevated sympathetic tone and reduced vagal activity — both associated with increased cortical excitability and lowered seizure threshold.
The 20% threshold is established in sports medicine as a clinically meaningful deviation from personal baseline (used by WHOOP, Oura, and published athlete-monitoring protocols). For epilepsy specifically, Jansen & Lagae (2010) showed preictal HRV depression in the hours preceding focal seizures, suggesting ANS dysregulation precedes seizure onset.
Limitation: Garmin's wrist-based optical HRV (rMSSD proxy from Firstbeat) has higher measurement noise than medical-grade ECG HRV. The 20% threshold compensates for this with a conservatively wide margin.
Garmin's stress score is itself computed from HRV (specifically the deviation of sympatho-vagal balance from a personal baseline via Firstbeat's algorithm). A score > 70 corresponds to Garmin's own "high stress" classification. Physiologically, sustained high stress activates the HPA axis (cortisol, adrenaline), which is a documented seizure risk factor: Bhagya et al. (2012) demonstrated that elevated cortisol measurably reduces seizure threshold in animal models; Temkin & Davis (1984) confirmed psychological stress as a self-reported trigger in 30–40% of epilepsy patients.
Note: This rule and the HRV-drop rule overlap mechanistically (both reflect sympathetic dominance). Simultaneous flags from both rules do not compound risk multiplicatively — each contributes one amber flag to the indicator independently.
Garmin's Body Battery is a proprietary composite score incorporating overnight HRV recovery, stress load, sleep quality, and activity level (via Firstbeat Analytics). A daily low below 20 indicates extreme overall depletion — essentially a corroborating composite signal when individual metrics are each only moderately elevated or unavailable. Chosen as a fallback / catch-all when Garmin HRV data is missing.
Regular moderate exercise is established as protective for epilepsy (Arida et al. 2008 — exercise reduces seizure frequency in most studies). This rule flags only excessive vigorous effort, not exercise in general. Mechanisms for excessive-exercise risk:
- Hyponatremia (dilutional, from high fluid intake during prolonged endurance effort) is a direct seizure trigger.
- Hypomagnesemia from sweat loss lowers seizure threshold.
- Extreme post-exercise fatigue amplifies the effect of concurrent sleep debt.
The 60 min threshold is pragmatic (not from a specific RCT) — it targets the minority of training days where vigorous-intensity duration is unusually high relative to typical Garmin daily summaries.
Elevated resting heart rate above personal baseline is an established early indicator of:
overtraining syndrome, acute viral illness, dehydration, and persistent psychological
stress — all of which are documented seizure risk factors or associated with other
flagged biomarkers. The 10% threshold follows standard sports-medicine practice for
flagging overtraining (Kreher & Schwartz 2012). Technically: ROUND(AVG(resting_hr))
over the 30 days prior to today, excluding today (to avoid comparing today against itself).
Limitation:
resting_hrfrom Garmin represents the lowest HR recorded during sleep, not a true clinical resting HR. Values can be affected by sleep position and movement artifacts, slightly increasing false-positive rates for this rule.
Selected references:
| Citation | Rule |
|---|---|
| Frucht et al. (2000). Epilepsy & Behavior 1(5). | 1 |
| Nakken et al. (2005). Epilepsia 46(1). | 1 |
| Bazil CW (2003). Lancet Neurol 2(5). | 1 |
| Malow BA (2004). Epilepsia 45(s10). | 1 |
| Jansen & Lagae (2010). Seizure 19(8). | 2 |
| Temkin & Davis (1984). Epilepsia 25(4). | 3 |
| Bhagya et al. (2012). Epilepsy Res 102(3). | 3 |
| Arida et al. (2008). Neuroscience Biobehav Rev 32(3). | 5 |
| Kreher & Schwartz (2012). Sports Health 4(2). | 6 |
Returns {"status": "ok"}. Not session-protected. Used by Docker healthcheck (liveness).
Readiness probe. Checks the DB connection and that at least one Flyway migration has
succeeded. Not session-protected. Returns {"status": "ready"} on success, or HTTP 503
with {"status": "no_migrations"} / {"status": "unavailable"} when the DB is not yet
ready.
Returns runtime saturation metrics collected via psutil (active/error request counters,
uptime, RSS memory, CPU percent, DB pool usage, p95 request duration). Session-protected
(requires a valid session).
{
"active_requests": 1,
"error_requests_total": 0,
"uptime_seconds": 4213,
"memory_mb": 84.2,
"cpu_percent": 0.3,
"db_pool_used": 2,
"db_pool_max": 10,
"p95_duration_ms": 42.0
}p95_duration_ms is null until at least 20 requests have been observed.