Skip to content

Latest commit

 

History

History
1329 lines (957 loc) · 40.1 KB

File metadata and controls

1329 lines (957 loc) · 40.1 KB

API Reference

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)


Authentication

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).


Error Format

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 locfield 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.


Public Routes

GET /login

Renders the login form.

POST /login

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.

GET /register

Renders the registration form.

POST /register

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}).

GET /privacy

Renders the privacy policy (privacy.html). No session required.

GET /terms

Renders the terms of service (terms.html). No session required.

GET /imprint

Renders the legal imprint (imprint.html). No session required.

GET /accessibility

Renders the accessibility statement (accessibility.html). No session required.

GET /auth/resend-verify

Renders a form to request a new verification email.

POST /auth/resend-verify

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.

GET /auth/verify/{token}

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.

GET /auth/reset-request

Renders the password reset request form.

POST /auth/reset-request

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.

GET /auth/reset/{token}

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.

POST /auth/reset/{token}

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).


Protected Pages (session required)

GET /

Redirects to /dashboard.

POST /logout

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.

GET /garmin/link

Renders the Garmin account linking form.

POST /garmin/link

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).

POST /garmin/unlink

Sets garmin_linked = false, clears garmin_email, and deletes the token row from user_tokens. Redirects to /.

GET /dashboard

Renders dashboard.html. The page loads data asynchronously via the /api/* endpoints below using fetch().

GET /activity/{activity_id}

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.

GET /settings

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.

GET /epilepsy

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.

GET /libre/link

Renders the LibreLinkUp linking form. When already linked, shows the connected email and a disconnect button instead of the form.

POST /libre/link

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).

POST /libre/unlink

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.

GET /ml/anomaly

Permanent redirect (HTTP 301) to /metrics/hr-zscore.

GET /ml/readiness

Permanent redirect (HTTP 301) to /metrics/readiness-rf.

GET /ml/correlations

Permanent redirect (HTTP 301) to /metrics/correlations.

GET /ml/battery

Permanent redirect (HTTP 301) to /metrics/battery-pattern.

POST /account/delete

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).

GET /account/delete/confirm/{token}

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.

POST /account/delete/confirm/{token}

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.

GET /account/export

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/json
  • Content-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).


JSON API (session required)

All endpoints return application/json. User data is always scoped to the authenticated user — cross-user access is not possible.


GET /api/activities

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

GET /api/activities/{activity_id}

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.


GET /api/daily

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
  }
]

GET /api/sleep

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
  }
]

GET /api/hrv

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

GET /api/hrv/trend

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"
  }
]

GET /api/weekly

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, …)

GET /api/readiness

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%

GET /api/training-status

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

GET /api/glucose

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 (↑↑)

GET /api/glucose/stats

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

GET /api/ml-history

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 }
  ]
}

GET /api/ml-insights

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.


GET /api/energy

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.


GET /api/training-load

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

PATCH /api/profile

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:

  • 422date_of_birth not in the past, or sex not in allowed values

PATCH /api/activities/{activity_id}/rpe

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 user
  • 422rpe out of range

GET /api/sync-status

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

GET /api/ml-status

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

GET /api/ml-feedback

Returns today's 👍/👎 feedback per model for the authenticated user.

Response — object mapping model key → boolean (helpful):

{ "readiness_rf": true, "anomaly_hr": false }

POST /api/ml-feedback

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:

  • 422model not in the supported set

GET /api/evidence

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": "...", "...": "..." }
}

Protected Pages (additional)

GET /metrics

Renders the metrics overview page (metrics_overview.html) — all metrics as tiles with their evidence badges. Session required.

GET /help

Renders the help & methodology page (help.html, client-side searchable, deep-linkable via /help#<metric-key>). Session required.

GET /metrics/{name}

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.


POST /api/seizures

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 }

GET /api/seizures

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"
  }
]

PATCH /api/seizures/{seizure_id}

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.


DELETE /api/seizures/{seizure_id}

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.


GET /api/seizures/risk

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

Rationale and scientific basis

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.


Rule 1 — Sleep debt (red ≥ 5h, amber 2–5h over 7 nights)

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.

Rule 2 — HRV drop > 20 % below personal weekly average

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.


Rule 3 — Garmin stress score (yesterday) > 70

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.


Rule 4 — Body battery daily low < 20

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.


Rule 5 — Vigorous intensity > 60 min (yesterday)

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.


Rule 6 — Resting HR > 10 % above 30-day personal baseline

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_hr from 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

Health & Metrics

GET /health

Returns {"status": "ok"}. Not session-protected. Used by Docker healthcheck (liveness).

GET /ready

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.

GET /api/metrics

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.