Telemetry API Endpoints
Layer 2 feature, not yet implemented. The Telemetry API is a separate Go service (
services/telemetry/). All endpoints live there, not in API. This doc describes the locked design — see index.md for the full architecture and rationale.
The Telemetry API serves two concerns:
- Patient-facing ingest — media events + pose batches. Authenticated via short-lived signed session token issued by API at session-run start (
POST /v1/session-runs). The terminalsession_endmedia event triggers the in-memory aggregator's PG flush; there is no separate finalizer endpoint and no callback to API. - Reads — none directly from clients. Telemetry's PG + S3 are read by API (through a read-only connection scoped to analytics endpoints), never by browsers.
Authentication
Signed session token (hot path)
Pose-frame ingest hits 10k+ requests/sec at peak. Verifying a Clerk JWT per batch is wasteful. Instead:
- Patient taps "Începe sesiunea" in Portal.
- Portal calls API:
POST /v1/session-runs(Clerk JWT auth as usual). - API creates the
session_runsrow and returns a short-lived signed token:json{ "run_id": "550e8400-e29b-41d4-a716-446655440000", "telemetry_token": "v1.eyJwcmluY2lwYWxfaWQiOiIuLi4iLCJvcmdfaWQiOiIuLi4iLCJydW5faWQiOiIuLi4iLCJleHAiOjE3NTQ5OTk5OTl9.signature", "telemetry_token_expires_at": "2026-05-07T14:00:00Z" } - Portal sends every pose/media batch to Telemetry API with
Authorization: Bearer <telemetry_token>. - Telemetry API verifies the JWT (signature +
iss+aud+exp) and reads consent state directly from theconsentsclaim. No second network call back to API per batch.
The token authorises ingest for one run — a full multi-exercise playthrough. The same token covers every /v1/media/events POST across every exercise within the run; per-exercise scope is carried in the media_id event field, not in the token.
JWT shape:
header: { alg: "HS256", typ: "JWT", kid: "<key version>" }
claims: {
iss: "restartix-api",
aud: "restartix-telemetry",
sub: <patient_id>,
iat, exp, // exp = iat + 90min
run_id, org_id, patient_id,
consents: { pose: bool }
}| Claim | Type | Purpose |
|---|---|---|
iss | string | Always "restartix-api". Telemetry rejects on mismatch. |
aud | string | Always "restartix-telemetry". Telemetry rejects on mismatch. |
sub | UUID | Patient principal id (patient_id). |
iat | int (unix) | Issue time. |
exp | int (unix) | Expiry; iat + 90min. No refresh endpoint — see "Token expiry mid-session" below. |
run_id | UUID | The session_runs.id the token authorises ingest for. |
org_id | UUID | Tenant scope. |
patient_id | UUID | Same as sub; surfaced explicitly for handler convenience. |
consents.pose | bool | Gates /v1/pose/frames. Mirrors biometric per-purpose consent at mint time. Engagement (playback-QoS) telemetry is GDPR Art. 6(1)(f) legitimate interest — no consent claim is carried for it. |
Signing: HS256. Shared HMAC secret in AWS Secrets Manager at restartix/{env}/telemetry-jwt-signing-key. API reads it for minting; Telemetry reads it for verifying. Both services need IAM grants in the env's Terraform.
Key rotation: the kid header claim is reserved for future multi-key support. For 1.3b a single active key is fine — the verifier accepts a map[kid]secret so rotation becomes a config-only change (publish next key, re-deploy with both, retire previous). Ed25519 swap-point preserved per index.md → Swap-point interfaces.
Token expiry mid-session: out of scope for MVP. 90 min covers the longest planned session + slop. If the token expires mid-session, Telemetry returns 401 and ingest stops for that run. No refresh endpoint; no mid-session reissue. Acceptable degradation — partial telemetry is still useful, and clinical-record events flow through API on Clerk auth which is independently refreshed.
Validation: Telemetry rejects with 401 if signature fails, any of iss/aud/exp mismatch, or token is malformed. Rejects with 403 only on /v1/pose/frames when consents.pose is false. Engagement routes (/v1/media/events, /v1/library/views) have no consent gate — audience + signature are sufficient. Run identity comes from the run_id claim — no path-param cross-check (the token IS the authorization).
Endpoints
POST /v1/pose/frames
Batched pose ingest. Client buffers ~1 second of frames and posts.
Request body (binary, Content-Type: application/octet-stream):
[1-byte version] [4-byte frame_count] [4-byte fps_hint]
[N × 33 × 4 × float32 landmarks] ← x, y, z, visibility per landmark per frame
[N × 4 byte timestamp_ms] ← elapsed ms since session_start, per frame
[gzip wrapper around the whole binary blob]Plus optional JSON sidecar with batch metadata in ?meta=... query param or X-Batch-Meta header (pose_confidence summary, camera_resolution, processing_time_ms — small, not per-frame).
Response: 202 Accepted
{
"frames_accepted": 10,
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"buffer_position_bytes": 5280
}Failure modes:
400— malformed binary, version unsupported401— token signature invalid / expired403— biometric consent not active413— batch too large (cap at ~64 KB after gzip)429— backpressure: drop policy says shed this batch (pose frames are dropable; aggregates are not)
Backpressure behavior: at high load, Telemetry API may drop pose-frame batches. Frames are fungible — losing 5% still gives 95% of an exercise's signal. The terminal session_end event on the media stream MUST NOT drop, as it carries the flush trigger; it has priority quota over pose batches.
POST /v1/media/events
Video lifecycle ingest. Volume is low (~110 events/sec at 1000 concurrent), so JSON is fine here.
Request body (Content-Type: application/json):
{
"event": "session_start",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"media_id": "exercise-uuid",
"media_type": "video",
"timestamp": "2026-05-07T10:00:00.000Z",
"data": {
"total_duration_seconds": 120.5,
"ttfb_ms": 340,
"video_load_time_ms": 1200,
"cdn_response_time_ms": 280,
"connection_type": "wifi",
"effective_bandwidth": 12.5,
"rtt_ms": 45,
"initial_bitrate": 2500000,
"initial_resolution": "720p"
}
}Full event taxonomy (video_played, video_paused, buffering_start, buffering_end, quality_change, watch_pct_25, watch_pct_50, watch_pct_75, watch_pct_95, page_visible, page_hidden, dropped_frames, session_end) and per-event field schemas live in media-events.md. The terminal session_end event triggers the in-memory aggregator's PG flush.
Response: 202 Accepted
{
"status": "accepted",
"session_id": "550e8400-e29b-41d4-a716-446655440000"
}Failure modes:
400— unknown event type, schema violation401— token invalid / expired403—analyticsconsent not active429— rate limit (per-session)
Run finalization
There is no separate finalizer endpoint. The terminal session_end event in the media-events stream is the flush trigger:
- Media half: receipt of
session_endfinalizes the in-memory aggregator and writes onemedia_session_metricsrow + Nmedia_buffering_eventsrows. - Pose half: receipt of
session_endfinalizes pose aggregation (rep count, ROM, completion), writespose_session_metrics+ per-rep rows, and concatenates the S3 in-flight prefix into the canonical replay blob.
Idempotency: the aggregator records "flushed" per run; a duplicate session_end is dropped server-side.
The s3://restartix-telemetry/{org_id}/{run_id}.bin.gz replay blob URL is internal — clients fetch replay via API (GET /v1/session-runs/{run_id}/replay) which mints a short-lived signed S3 URL.
GET /v1/healthz
Internal health check for ALB. Returns 200 OK with {"status": "ok", "version": "..."}. Not authenticated; not exposed publicly.
Reads (none on Telemetry API)
There are no read endpoints on Telemetry API for clients. All reads flow through API:
| Reader → endpoint | Source data |
|---|---|
Patient: GET /v1/me/session-runs | pose_session_metrics + media_session_metrics (PG, RLS by principal) |
Specialist: GET /v1/patients/{id}/session-runs | Same tables, RLS by org + permission |
Specialist: GET /v1/session-runs/{run_id}/replay | Signed S3 URL to replay blob |
Clinic admin: GET /v1/analytics/cohort/exercise-adherence | Materialized view over pose_session_metrics |
Console: GET /v1/admin/platform/exercise-aggregate-counts | Anonymised aggregates only (no principal_id in response) |
This is by design: API is the single source of authentication, RLS, audit, classification, and per-org permission enforcement. Telemetry API is ingest-only.
Run lifecycle (end-to-end)
Portal API Telemetry API Telemetry PG S3
│ │ │ │ │
├── POST /v1/session-runs ─► │ │ │
│ │ create session_runs │ │ │
│ │ row, mint JWT │ │ │
◄── 200 (run_id, telemetry_token) ── │ │ │
│ │ │ │ │
├── POST /v1/media/events (token) ────────────────► │ verify JWT, read │ │
│ { event: "video_played" / "buffering_*" / ... } │ consents claim, │ │
│ │ │ accumulate in mem │ │
│ │ ◄── 202 │ │
│ │ ... many events ... │ │
│ │ │ │ │
├── POST /v1/pose/frames (token) ────────────────► │ verify, append │ │
│ (binary, ~1s batch) │ │ to in-flight ─────────────────────────►
│ │ ◄── 202 │ │
│ │ ... many batches ... │ │
│ │ │ │ │
├── POST /v1/media/events (token) ────────────────► │ session_end: │ │
│ { event: "session_end" } │ │ ├ flush media to │ │
│ │ │ │ PG ───────────► │
│ │ │ └ flush pose to │ │
│ │ │ PG + finalize │ │
│ │ │ S3 blob ────────────────────────► │
│ │ ◄── 202 │ │
│ │ │ │ │
│ ... post-launch: specialist views ... │ │ │
│ ◄── GET /v1/session-runs/{run_id}/analytics (Clinic app) │
│ │ read telemetry PG ──────────────────────► │ │
│ ── 200 ──► │ │ │The API endpoint that reads telemetry PG is post-launch (analytics dashboards). At MVP, telemetry writes its tables but no client-facing read surface consumes them yet.
Idempotency
- Pose batches are not idempotent — duplicate batches are appended (small cost). Client should not retry pose batches; on failure, the next batch overlaps anyway.
- Media events are idempotent by
(session_id, event_type, timestamp)— duplicates dropped at ingest. - The terminal
session_endis idempotent — the aggregator records "flushed" per run; a duplicatesession_endis dropped server-side without re-writing PG rows.
Rate limiting
Per-session quota: ~1 batch/sec per endpoint (matches client batch cadence). Burst allowance ~5 batches/sec for reconnect-and-flush cases. Per-org global quota: based on entitlements + 1C.7 metering — exceeding the org's quota returns 429 across all sessions.
Observability
Telemetry API itself emits OTel traces / metrics / logs to the platform's observability stack (Datadog at scale, CloudWatch at staging). These are operational signals about the service — separate concern from the product telemetry it ingests.
Migration / older spec
Earlier versions of this doc described POST /v1/analytics/track, POST /v1/errors/report, POST /v1/audit/ingest, and an admin/dashboard surface. All four are out — see index.md → Scope for what each was replaced by (or rejected). When Layer 2 builds the service, this doc is the canonical reference; older references in features specs will be cleaned up at that time.