# Hyperhuman Content API — Developer Cheat Sheet (OAS3)
**The Fitness Content OS for Your Product**  
**Base URL:** `https://content.api.hyperhuman.cc`  
**Auth:** Add header **`X-Api-Key: YOUR_API_KEY`** to every request.

> **Why this doc?** Minimal, copy‑pasteable, and easy to keep in sync with Swagger. One page, clear flows, working examples.

> **Routes that 404.** Only paths in the [public OpenAPI](https://content.api.hyperhuman.cc/openapi.json) exist. Do not call `GET /v1/workouts` (no unscoped list — use `GET /v1/orgs/{orgId}/workouts`) or `GET /v1/orgs/{orgId}/programs` (programs are plans: `GET /v1/orgs/{orgId}/plans`, or a member's library at `GET /v1/orgs/{orgId}/endusers/{endUserId}/plans`). The Content API is **key-authenticated**: there are no sign-in, token, or account-management routes to call — end-user accounts are managed by the branded member apps, and you identify a member on API calls with `{endUserId}` (or `endUserId` / `externalUserId` fields). List calls use `limit` / `offset` (or `links.next`), not a made-up collection name.

> **Conventions (2026):** Resource ids are **24-character hex** strings — treat them as **opaque** (do not parse as something else). For `.../endusers/{endUserId}/...` paths, the server also accepts an **email** or **external id** where OpenAPI says so; **prefer the hex id** in production. **Strict request shape:** do not add query or JSON fields that are not in the [public OpenAPI](https://content.api.hyperhuman.cc/openapi.json) for that operation; **extra keys usually return HTTP 400** (often `error.code: ValidationError`). The public spec is a **curated** product surface; the definitive list of supported operations is **`/openapi.json`**, not an informal route list. Full details: [AGENTS.md](https://content.api.hyperhuman.cc/AGENTS.md) (sections 3, 6, 11).


## Quick Navigation
- [Quick Start](#0-quick-start-3-steps)
- [API Map](#1-api-map-most-used)
- [End-to-End Flows](#2-endtoend-flows-overview)
- [Request Examples](#3-request-examples)
- [Recommendations vs Generation](#4-recommendations-vs-generation)
- [Programming DNA & Generation Inspector](#programming-dna-methodology-profiles)
- [Recipes: library rotation & ordered programs](#recipes-library-rotation--ordered-programs)
- [User Management](#5-user-management)
- [Localization](#6-localization)
- [Security & Rate Limiting](#7-security--rate-limiting)
- [HyperCast (Cast to TV & remote control)](#hypercast-cast-to-tv--remote-control)
- [Complete Endpoint Reference](#8-complete-endpoint-reference)
- [Organization Management](#9-organization-management)
- [Social Drops](#social-drops)
- [Organization exercise library (Smart Library)](#organization-exercise-library-smart-library)
- [Library search behavior](#library-search-behavior)
- [Exercise Groups (Micro Workouts)](#exercise-groups-micro-workouts)
- [AI User Insights](#14-ai-user-insights)
- [Health Data Push (batch write)](#health-data-push-batch-write)
- [Direct Nutrition Logging](#direct-nutrition-logging)
- [Nutrition Photo Logging](#nutrition-photo-logging-two-step)
- [Webhooks & build-your-own retention](#webhooks--build-your-own-retention)

---

## 0) Variables (set once)
```bash
export API_KEY="YOUR_API_KEY"
export ORG="YOUR_ORG_ID"
export WID="REPLACE_WITH_WORKOUT_ID"
export SID="REPLACE_WITH_SESSION_ID"
export PID="REPLACE_WITH_PLAN_ID"
export DID="REPLACE_WITH_DROP_ID"
export GID="REPLACE_WITH_GROUP_ID"
export EMAIL="user@example.com"
export ENDUSER="6821e6ad9d20276ef3c3c9ec"   # endUserId from GET /v1/orgs/$ORG/endusers/find?email=$EMAIL (preferred over an email in the path)
```

---

## 1) Quick Start (3 steps)
1) **Get API Key** → `team.hyperhuman.cc/settings?tab=integrations`  
2) **List categories & difficulties**
```bash
curl -s 'https://content.api.hyperhuman.cc/v1/workouts/metadata' -H "X-Api-Key: $API_KEY"
```
3) **Generate a workout** (ephemeral JSON — no `id`; do not playlist this body). Free-text is `userRequirements`, not `userGuidance`. Allow **120s**.
```bash
curl -s --max-time 120 -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/generate" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 28, "fitnessLevels": ["beginner"]},
    "exerciseSources": ["premium_stock"],
    "durationOptions": {"minMinutes": 20, "maxMinutes": 20},
    "categoryId": "615aa3f0dbe7140012c59e94"
  }'
```

> **Tip:** Cache metadata + feedback options; they rarely change during a session. There is **no** `GET /v1/workouts` list — catalog is `GET /v1/orgs/$ORG/workouts`.

---

## 2) API Map (most used)
| Area | Endpoint | Purpose |
|---|---|---|
| **Org workouts** | `GET /v1/orgs/{orgId}/workouts` | Browse **workspace** workouts (empty **200** until added in Teams; not global stock templates). There is **no** `GET /v1/workouts` list and **no** `/library` or `/categories` routes. Categories live on `GET /v1/workouts/metadata`. |
| Workout details | `GET /v1/workouts/{workoutId}` | Title, trainer, media |
| **Similar workouts** | `GET /v1/workouts/{workoutId}/similar` | Same-org attribute matches. Not recommend. `limit` 1–10 (default 5). `visibility` default `public`. **1x** |
| Workout playlist | `GET /v1/workouts/{workoutId}/playlist` | Ordered segments. Video/audio `url` = presigned MP4/M4A (~7d) — play **verbatim** (no API key on the file, no query rewrite). Exercise-family `poster`/`thumbnail` = public unsigned S3 JPEG (not the MP4; intro/outro omit poster). `exercise.duration` = work window; loop the (often shorter) clip |
| Workout music | `GET /v1/workouts/{workoutId}/music` | PulseMix tracks (same signed-URL contract as playlist video) |
| Video export | `GET /v1/workouts/{workoutId}/export/video/stream_url` | `text/plain` presigned URL to the latest ready full video (404 if none). Not created on every save |
| Audio export | `GET /v1/workouts/{workoutId}/export/audio/stream_url` | `text/plain` presigned URL to full narrative audio |
| Team methodologies | `GET /v1/orgs/{orgId}/methodologies` | Team Programming DNA profiles (up to **10**; `ruleSummary`, no full text). Empty `{ items: [] }` is **200**. **1x**. Listing never applies a profile. |
| AI generate workout | `POST /v1/orgs/{orgId}/workouts/generate` | Ephemeral personalized workout (200 body has **no `id`** — do not playlist). Free-text = `userRequirements` (not `userGuidance`). Optional `methodologyProfileId`. May return Generation Inspector (`methodology` / `inspection`). Client timeout **120s**. |
| Workout generate preflight | `POST /v1/orgs/{orgId}/workouts/generate/preflight` | Dry-run the generate body (feasibility + hard rules). No workout created. **1x**. Not the Inspector snapshot. |
| **Recommend workout** | `POST /v1/orgs/{orgId}/workouts/recommend` | Instant suggestions from library |
| **Adapt workout** | `POST /v1/orgs/{orgId}/workouts/{workoutId}/adapt` | Personalized variant of an **existing** workout. Free-text = `userGuidance` (not `userRequirements`). Client timeout **120s**. |
| **Plans (org)** | `GET /v1/orgs/{orgId}/plans` | Browse **workspace** plans (add pre-made stock in Teams first) |
| Plan details | `GET /v1/plans/{planId}` | Overview (no embedded workouts) |
| **Similar plans** | `GET /v1/plans/{planId}/similar` | Same-org attribute matches. Not recommend. Excludes member copies. **1x** |
| Plan workouts | `GET /v1/plans/{planId}/workouts` | **Returns full list (no server pagination)** |
| AI generate plan | `POST /v1/orgs/{orgId}/plans/generate` | Multi‑week programs. Top-level `id` **only** with `endUserProfileDetails.endUserId` (`workoutCollectionSources`). Free-text = `userRequirements`. Optional `methodologyProfileId`. May return Generation Inspector (`methodology` / `inspection`). No plan preflight. Client timeout **120s**. |
| **Recommend plan** | `POST /v1/orgs/{orgId}/plans/recommend` | Instant plan suggestions |
| **Adapt plan** | `POST /v1/orgs/{orgId}/plans/{planId}/adapt` | **Requires** `endUserProfileId` + `workoutSources`. Active enrollment → **next slot only**; else full future schedule. End-user generated plans (`GET /v1/plans/{id}`) are in-scope; catalog-derived copies that are not active → **400** |
| **End-user library** | `GET /v1/orgs/{orgId}/endusers/{endUserId}/workouts` | Published workouts assigned to this user from this org (not the org catalog) |
| End-user library | `POST .../workouts/assign` · `POST .../workouts/unassign` | Add / remove workouts (max 10; assign 400 if any id ineligible; unassign ignores missing/non-org ids) |
| End-user library | `GET /v1/orgs/{orgId}/endusers/{endUserId}/plans` | Catalog programs in this user's library (not `GET .../plans/active`) |
| End-user library | `POST .../plans/assign` · `POST .../plans/unassign` | Add / remove programs (max 5; assign does **not** set `isActive`; unassign of the active plan deletes enrollment) |
| **End-user plans** | `POST /v1/orgs/{orgId}/endusers/{endUserId}/plans/start` | Start plan (`planId`, optional `restart`, `autoAdapt`, `dropMissedAtWeekEnd`; idempotent when already active) |
| End-user plans | `GET /v1/orgs/{orgId}/endusers/{endUserId}/plans/active` | Active plan + schedule (`startDate`, `autoAdapt`, `dropMissedAtWeekEnd`, `adaptationReasoning` on enrolled copy) |
| End-user plans | `PATCH /v1/orgs/{orgId}/endusers/{endUserId}/plans/active/settings` | Update `autoAdapt` and/or `dropMissedAtWeekEnd` on the active enrollment |
| End-user plans | `POST /v1/orgs/{orgId}/endusers/{endUserId}/plans/active/slots/{position}/skip` | Skip any open slot, including one after the due slot. No completion, no push, no email. `400` if the position is not in the plan. `404` if there is no active plan. `409` if the flag is off or the slot is already finished or skipped. Drops that user's insights digest |
| End-user plans | `GET /v1/orgs/{orgId}/endusers/{endUserId}/plans/active/progress` | Per-position completion (`latestCompletionDate`, `skipped`; no `source`; sessions on/after `startDate` with `progress >= 0.1`; `programPosition` pins a repeated workout) |
| End-user plans | `POST /v1/orgs/{orgId}/endusers/{endUserId}/plans/{planId}/end` | End active plan |
| **Org exercise library** | `GET /v1/orgs/{orgId}/video-assets` | List **workspace-only** exercise videos (no stock / pay-as-you-go; `single-exercise` rows include `audioInstructions[]` per locale). Filters: `q`, `equipmentIds`, `muscleGroupIds`, `kinds`, `skillLevels`, `executionSides`, `coach`, `collectionNames`. Sort: `date` / `name` / `kind` (`+`/`-` prefix) |
| **Stock exercise catalog** | `GET /v1/orgs/{orgId}/stock-exercises` | **Ultra (annual, Stripe `active`)** or **custom/Enterprise** with `stockVideoCatalogApi` (+ `contentApi`). Ultra monthly / Platform / trial → **403** (no consume). Content with no API → **401**. Browsable **published, public, `single-exercise`** stock clips (free + premium). Filters mirror `video-assets` plus `availability`. **10x** when admitted. Not AI-generation fuel. Not the marketing-site stills browse. |
| **Exercise groups (micro workouts)** | `GET /v1/orgs/{orgId}/groups` | List org micro workouts (circuit/sets) |
| Micro workout detail | `GET /v1/orgs/{orgId}/groups/{groupId}` | Full detail with execution config |
| Micro workout exercises | `GET /v1/orgs/{orgId}/groups/{groupId}/exercises` | Exercises in collection |
| **Sessions lifecycle** | `POST start` · `PATCH {sessionId}` · `POST end` · `POST complete` | Track usage. Optional `programPosition` on start, end, and complete names the active-program slot |
| **HyperCast join** | `POST /v1/cast/pairing/join-by-code` | Pair a controller with the member-web `/cast` TV page (production `https://member.hyperhuman.cc/cast?orgId=...`; 6-digit code; one controller per session; OpenAPI tag `workouts-cast`) |
| HyperCast cast + play | `POST .../{sessionId}/workout` · `POST .../{sessionId}/workout/start` | Load a workout on the display, then start playback (optional `endUserId` / `externalUserId` / `organizationId` on start; Bearer does not attribute; non-member → `404`) |
| HyperCast control | `POST .../{sessionId}/workout/control` · `GET .../{sessionId}/workout/status` | `play` / `pause` / `next` / `prev` / `restart` / `done_exercise` / `done`; poll status as the no-socket fallback |
| **User management** | `GET /v1/orgs/{orgId}/endusers` | List organization users (paginated, filtered) |
| User management | `GET /v1/orgs/{orgId}/endusers/find` | Find user by email or external id |
| User management | `POST /v1/orgs/{orgId}/endusers/invite` | Invite users (batch up to 100; optional `sendInviteEmail`) |
| User management | `PUT /v1/orgs/{orgId}/endusers/suspend` | Suspend user access |
| User management | `PUT /v1/orgs/{orgId}/endusers/reactivate` | Reactivate suspended user |
| User management | `DELETE /v1/orgs/{orgId}/endusers` | Permanently remove user |
| **Get recent session** | `GET /v1/workouts/{id}/sessions/recent` | End-user's most recent **in-progress** (not completed) session, for resume-first UX. Needs an **identity**: a member `Authorization: Bearer` token **or** the `endUserId` / `externalUserId` **query** fields. A fully anonymous key-only call has no identity to resolve → `404` |
| Feedback | `GET /v1/workouts/feedback/options` · `POST /v1/workouts/{id}/feedback` | Rating & difficulty. Submit option **ids**: `{ workoutSessionId, ratingId?, difficultyId?, comment? }` |
| **Session attribution** | end-user fields on every session/feedback call | Key-only sessions are **anonymous** (`traineeId: null`). Attribute without a member token by sending `endUserId` (id/email) **or** `externalUserId` (+ `organizationId` for standard/club keys spanning orgs) — body for `POST`/`PATCH`, query for `GET`. A Bearer token wins when present. **Exception:** HyperCast `workout/start` ignores Bearer — fields + API key only; non-member → `404` |
| Metadata | `GET /v1/workouts/metadata` · `GET /v1/workouts/equipment/metadata` · `GET /v1/workouts/exercises/metadata` | Build filters/UI |
| **Organization** | `GET /v1/orgs/{orgId}/metadata` · `GET /v1/orgs/{orgId}/join-options` · `GET /v1/orgs/{orgId}/stats` | Branding, settings, usage stats |
| **Social drops** | `GET /v1/orgs/{orgId}/drops` | List ready drops (video, cover, copy) |
| Drop detail | `GET /v1/orgs/{orgId}/drops/{dropId}` | Full detail with variants and copy packs |
| Export drop | `POST /v1/orgs/{orgId}/drops/{dropId}/export` | Channel bundle (video URL, cover, copy pack) |
| **Social publish** | `GET /v1/orgs/{orgId}/social-publish/connected-platforms` | Which platforms have stored account ids (link accounts in team app) |
| Social publish | `GET /v1/orgs/{orgId}/social-publish` | List scheduled social publish automations |
| Social publish | `POST /v1/orgs/{orgId}/social-publish` | Create automation (draft) |
| Social publish | `GET /v1/orgs/{orgId}/social-publish/{taskId}` | Get automation + last run fields |
| Social publish | `PATCH /v1/orgs/{orgId}/social-publish/{taskId}` | Update; use `status` active/draft (no run-now on Content API). A third status `paused` is **system-set** after repeated connection failures (readable/filterable, not writable — 400); resume with `status: active` after reconnecting channels |
| Social publish | `DELETE /v1/orgs/{orgId}/social-publish/{taskId}` | Soft delete automation |
| **AI Insights** | `GET /v1/orgs/{orgId}/endusers/{userId}/insights/digest` | Daily digest + readiness signals |
| Pillar detail | `GET /v1/orgs/{orgId}/endusers/{userId}/insights/pillars/{type}` | Coaching details + why explanations |
| **Health data push** | `POST /v1/orgs/{orgId}/endusers/{userId}/insights/health-data` | Batch push (max 50): activities, sleep, steps, body metrics, HR, hydration, SpO2, pre-computed scores — feeds digest/pillars like the wearable sync |
| Health data delete | `DELETE /v1/orgs/{orgId}/endusers/{userId}/insights/health-data/{dataType}/{externalId}` | Delete a pushed entry + recompute the day |
| **Nutrition photo log** | `POST /v1/orgs/{orgId}/endusers/{userId}/nutrition/photo-log` | Vision AI meal-photo analysis → pending estimate (counts as **10x** API calls) |
| Nutrition photo confirm | `POST /v1/orgs/{orgId}/endusers/{userId}/nutrition/photo-log/{pendingLogId}/confirm` | Confirm pending estimate → nutrition logged (1x) |
| Nutrition log | `POST /v1/orgs/{orgId}/endusers/{userId}/nutrition/log` | Log macros directly (same pipeline as chat/photo logging); optional `externalId` for idempotent upsert |
| Nutrition log delete | `DELETE /v1/orgs/{orgId}/endusers/{userId}/nutrition/log/{externalId}` | Remove a logged entry, subtract from day totals |
| **Content Autopilot** | `GET` · `POST /v1/orgs/{orgId}/content-autopilot` | List / create scheduled content automations (generate, clone, or publish on a recurring schedule) |
| Content Autopilot | `GET` · `PATCH` · `DELETE /v1/orgs/{orgId}/content-autopilot/{taskId}` | Get / update / soft-delete an automation |
| Content Autopilot | `POST /v1/orgs/{orgId}/content-autopilot/{taskId}/activate` · `.../deactivate` · `.../run-now` | Activate, pause, or manually trigger an automation |
| Content Autopilot | `GET /v1/orgs/{orgId}/content-autopilot/events` · `.../{taskId}/events` | Execution event history (success / failed / skipped) |
| **AI Chat** | `GET` · `POST /v1/chat/conversations` | List / start AI coach conversations (`organizationId` + `userEmail`\|`userExternalUUID` are body/query fields, not path params; one user identity is **required**) |
| AI Chat | `GET` · `POST /v1/chat/conversations/{conversationId}/messages` | Read / send messages in a conversation |
| AI Chat | `POST /v1/chat/conversations/{conversationId}/actions/confirm` · `DELETE /v1/chat/conversations/{conversationId}` | Confirm a pending AI action / archive a conversation |

---

## 3) End‑to‑End Flows (overview)

**A) Browse & Play (organization content — persisted workout ids)**

Workspace catalog only. Empty `data: []` + `links.total: 0` is a successful empty library — add workouts in Teams (including **Add from stock**) before this list is non-empty. Do not invent ids or play generate JSON via `/playlist`.
```
Client → GET /orgs/{orgId}/workouts → pick workoutId from organization library
Client → GET /workouts/{id} → display details
Client → GET /workouts/{id}/playlist → play segment `video.url` **verbatim** (presigned MP4; no API key on the file); thumbs from `video.poster` (public JPEG; intro/outro have none)
(Optional) GET /workouts/{id}/music → PulseMix tracks (persisted id only)
(Optional) POST /workouts/{id}/sessions/start → track progress
Client → PATCH /workouts/{id}/sessions/{sessionId} (progressSeconds: REQUIRED actual playback time)
Client → PATCH every 10-30s with current progressSeconds (PRIMARY duration tracking method)
Client → PATCH final progressSeconds before ending
Client → POST /workouts/{id}/sessions/end (uses last PATCH duration)
       OR POST /workouts/{id}/sessions/complete (force 100%)
         Active program: send programPosition (that slot's positionInPlan) on start, end, and complete
Client → POST /workouts/{id}/feedback (rating/difficulty)
```

**B) AI Generate (ephemeral workout JSON — no top-level workout id)**
```
Client → GET /orgs/{orgId}/methodologies  (optional; pick a team Programming DNA id)
Client → POST /orgs/{orgId}/workouts/generate/preflight  (optional 1x feasibility check)
Client → POST /orgs/{orgId}/workouts/generate → { name, instances[] (single-exercise|rest), totalDurationSeconds, methodology?, inspection?, … }
Client → iframe /embed/workout-json-receiver?organizationId=… (wait for hyperhumanPlayerReadyForData)
Client → postMessage HYPERHUMAN_EMBED_INIT { apiKey, workout }  // pass generate JSON as-is
         Player waits for a non-empty apiKey, then GET /video-asset/{exerciseId} per exercise (1x each; no /playlist, /music, or sessions)
OR custom player from instances[] + GET /video-asset/{id}
(Optional) store the JSON in your DB for replay. Persist methodology / inspection if you need a Generation Inspector audit — there is no GET-by-generation-id
```

Free-text field is `userRequirements` (`userGuidance` → **400**). Optional `methodologyProfileId` from `GET .../methodologies` (opt-in). Keep the HTTP client open ≥ **120s**. Do not mint local hex ids and poll `/playlist` on them. `methodology` / `inspection` are partner metadata — not playback fields.

**B2) Recommendations (Fast Discovery — library ids)**
```
Client → POST /orgs/{orgId}/workouts/recommend → returns ranked workouts
Client → Pick workoutId from recommendations
Client → GET /workouts/{id}/playlist → play content
Client → [Follow session tracking from Flow A]
```

**C) Training Plans (multi‑week)**
```
Client → GET /v1/plans/metadata → goals & difficulties
Client → GET /orgs/{orgId}/methodologies  (optional; pick a team Programming DNA id)
Client → POST /orgs/{orgId}/plans/generate
         Include endUserProfileDetails.endUserId → top-level planId (store it)
         Omit endUserId → program JSON with no top-level id (cannot GET /plans/{id})
         Optional methodologyProfileId (opt-in). No plan generate preflight on this API
         Body sources field: workoutCollectionSources (not workoutSources)
         Persist methodology / inspection from the generate body if you need a Generation Inspector audit
         Later GET /plans/{id} does not replay that snapshot
Client → GET /v1/plans/{planId} → high‑level info (only when you stored an id)
Client → GET /v1/plans/{planId}/workouts → full workout list (no pagination)
For each workout → follow Flow A (sessions/feedback)
```

**C2) Plan Recommendations (Fast Discovery)**
```
Client → POST /orgs/{orgId}/plans/recommend → returns ranked plans
Client → Pick planId from recommendations
Client → GET /v1/plans/{planId}/workouts → get workout list
For each workout → follow Flow A (sessions/feedback)
```

**C3) End-user library (assign / unassign, API key)**
```
Client → GET /v1/orgs/{orgId}/workouts (or GET /v1/orgs/{orgId}/plans) → pick published public/private ids
Client → POST /v1/orgs/{orgId}/endusers/{endUserId}/workouts/assign  { workoutIds: [...], sendAssignmentEmail?, sendAssignmentPush? }
         Max 10 workout ids. Any ineligible id → 400 and nothing is written. Already-assigned ids are skipped (no re-notify). Safe to retry.
Client → POST /v1/orgs/{orgId}/endusers/{endUserId}/plans/assign     { planIds: [...], sendAssignmentEmail?, sendAssignmentPush? }
         Max 5 plan ids. Does **not** enroll — call plans/start to set the active plan. Start does not require a prior assign.
Client → GET .../endusers/{endUserId}/workouts  ·  GET .../endusers/{endUserId}/plans  → this org's assigned library
Client → POST .../workouts/unassign  ·  POST .../plans/unassign  → remove org-owned access (idempotent; no notification)
```
Unassign ignores missing ids and ids that are **not this organization's** content (does not 400). Unassigning the **active** plan **deletes** the access row (enrollment ends). That is stronger than `POST .../plans/{planId}/end`, which only clears `isActive` and keeps the program in the library. Suspended / unknown members → **404**. Team API key only.

**C4) End-user plan lifecycle (org-scoped, API key)**
```
Client → POST /v1/orgs/{orgId}/endusers/{endUserId}/plans/start (body: { planId, restart?: boolean, autoAdapt?: boolean, dropMissedAtWeekEnd?: boolean })
         Call once at enrollment. Idempotent when the same plan is already active (omit restart or restart: false).
         Use restart: true only to reset progress (new startDate; prior sessions no longer count toward progress; stored skips are cleared).
         Set autoAdapt: true to enable automatic next-workout adaptation for this enrollment.
         dropMissedAtWeekEnd is on when omitted. Unfinished slots in fully elapsed weeks before currentWeekNumber are skipped. That week stays due after the plan length. A later week still drops once it has elapsed. Send false to keep missed workouts due.
Client → PATCH /v1/orgs/{orgId}/endusers/{endUserId}/plans/active/settings (body: { autoAdapt?: boolean, dropMissedAtWeekEnd?: boolean })
         Update either flag without restarting the plan. Turning dropMissedAtWeekEnd off keeps skips that already happened.
Client → POST /v1/orgs/{orgId}/endusers/{endUserId}/plans/active/slots/{position}/skip
         Skip any open slot (0-based), including one after the due slot. Does not write a completed session.
         400 when the position is not in the program. 404 when there is no active plan.
         409 when the flag is off or that slot is already completed or skipped.
         Does not push or email. Drops that user's insights digest. The next digest may show highlight kind slot_skipped.
         Progress does not include a source field. A week-boundary drop is not this route.
Client → GET /v1/orgs/{orgId}/endusers/{endUserId}/plans/active → active plan + schedule (includes startDate, autoAdapt, dropMissedAtWeekEnd)
Client → GET /v1/orgs/{orgId}/endusers/{endUserId}/plans/active/progress → per-slot completion
         Keys are position indices ("0", "1", …). A session counts when it is completed on/after startDate and progress >= 0.1.
         Field name is latestCompletionDate (not lastCompletedDate). skipped is true only when that slot has no completion.
         Send programPosition (positionInPlan) on sessions/start, sessions/end, and sessions/complete to fill that copy
         of a workout that appears more than once. Without it, the earliest open matching slot is filled (a skipped copy only when no open copy is left).
         A completion of a skipped slot clears skipped for the slot that was filled.
Client → POST /v1/orgs/{orgId}/endusers/{endUserId}/plans/{planId}/end → end plan
```
(endUserId can be email, externalUUID, or internal user ID; same as AI Insights.)

**Do not** call `plans/start` on every page load or before every progress poll — that used to reset `startDate` and flip `completed` back to `false` even when workout sessions existed in the app UI. **Do not** use `POST .../plans/assign` to enroll — it only adds library membership.

**D) AI Adaptation (Customize Existing Content)**
```
Client → Have existing workoutId or planId from library
Client → POST /orgs/{orgId}/workouts/{workoutId}/adapt
         → ephemeral { name, instances[] } (no workout id) → play via JSON receiver / custom player
       OR POST /orgs/{orgId}/plans/{planId}/adapt
         body MUST include endUserProfileId (+ endUserProfileDetails + workoutSources)
         → persisted adapted plan id (store it; GET /plans/{id}); omit endUserProfileId → 400
         Active enrollment for that user → next workout slot only; else full future schedule
```

**D2) When to Use Each AI Method**

| Scenario | Use Recommendations | Use Generation | Use Adaptation |
|----------|-------------------|-----------------|----------------|
| **New User Onboarding** | ✅ Fast discovery from library | ❌ Too slow for first impression | ❌ No existing content yet |
| **Personalized Workouts** | ❌ Limited to existing matches | ✅ 100% customized content | ✅ Preserve workout intent |
| **Content Library Size** | ✅ 20+ pre-built workouts | ✅ Any size library | ✅ Existing workout to adapt |
| **Response Time** | ✅ <100ms instant | ❌ 2-5s processing | ❌ 3-8s processing |
| **Content Consistency** | ✅ Matches brand standards | ❌ Variable quality | ✅ Maintains brand standards |
| **Cost Efficiency** | ✅ Lowest API cost | ❌ Highest API cost | ✅ Medium API cost |
| **User Experience** | ✅ Immediate results | ❌ Loading delays | ❌ Loading delays |
| **Content Goals** | ❌ Pre-defined goals only | ✅ Any user goals | ✅ Adapts existing goals |

**Decision Framework:**
- **Speed matters most** → Recommendations
- **Perfect personalization needed** → Generation (workout = ephemeral JSON; plan needs `endUserId` for a storeable `id`)
- **Preserve workout/plan structure** → Adaptation (plan adapt needs `endUserProfileId`)
- **Large content library available** → Recommendations
- **Cost-conscious implementation** → Recommendations then Adaptation

**D) User Management Lifecycle**
```
Client → POST /orgs/{orgId}/endusers/invite → add users (batch up to 100)
         Optional body.sendInviteEmail (omit = org Invite emails setting; false = you send your own)
         Response inviteEmailSent = batch send policy for new invites (not a delivery receipt)
Client → GET /orgs/{orgId}/endusers/find?email=user@example.com → verify status
Client → GET /orgs/{orgId}/endusers → list all users (paginated)
Client → PUT /orgs/{orgId}/endusers/suspend → temporarily disable access
Client → PUT /orgs/{orgId}/endusers/reactivate → restore access
Client → DELETE /orgs/{orgId}/endusers → permanent removal
```
Invite creates a **pending** invite; the end user accepts in the member experience (or branded Redirect URL). Enroll/accept flow is unchanged when Hyperhuman transactional invite email is skipped.

**E) Embed Player (no app build required — always iframe, host `team.hyperhuman.cc`)**
```
Pre-built: /embed/workout/{workoutId}?organizationId={orgId}&apiKey={apiKey}&locale=en-US
  → API: GET /workouts/{id} + /playlist (+ /music); optional sessions/feedback (sessionMode/endUserId)
  → Hardened: drop apiKey, add keyHandshake=1, send key via HYPERHUMAN_EMBED_INIT
  → those GETs + sessions are 1x on that key (no separate embed-play unit)
AI-generated: /embed/workout-json-receiver?organizationId={orgId}
  → HYPERHUMAN_EMBED_INIT { apiKey, workout: <generate JSON> }
  → wait for non-empty apiKey, then GET /video-asset/{exerciseId} (1x each); no PulseMix / sessions / feedback
Host tips: allow="autoplay; fullscreen; accelerometer; gyroscope" + allowfullscreen;
  full-viewport (100dvh) for mobile; optional HOST_IMMERSIVE / ORIENTATION_TOGGLE; BGM off = pause
```

**F) AI Insights (Daily Digest)**
```
Client → GET /orgs/{orgId}/endusers/{userId}/insights/digest → pillars + coaching signals
Client → Display digest (3-5 pillars with status + readinessBand)
Client → User taps pillar for detail
Client → GET /orgs/{orgId}/endusers/{userId}/insights/pillars/{type} → why + alternatives
(Optional) Force refresh after workout: ?forceRefresh=true
```

**F1b) Health Data Push (partner wearable/app data)**
```
Partner backend → POST /orgs/{orgId}/endusers/{userId}/insights/health-data { entries: [...] } (max 50; 1x)
  → per-entry results: created / updated / rejected + summary (always 200 - check results!)
Re-POST same (dataType, externalId) → idempotent upsert (safe retries, corrections)
DELETE .../insights/health-data/{dataType}/{externalId} → remove entry + recompute day
Digest / pillars pick up the pushed data on next read (same path as wearable-synced data)
Note: activity entries require externalId; day-scoped types default to day-YYYY-MM-DD
```

**F2) Nutrition Photo Logging (vision AI, two-step)**
```
Client → POST /orgs/{orgId}/endusers/{userId}/nutrition/photo-log (multipart photo; 10x) → pendingLogId + estimate
Client → Show estimate (items, totals, confidence); let user adjust totals
Client → POST .../nutrition/photo-log/{pendingLogId}/confirm (optional corrected totals; 1x) → logged + day totals + nutrition score
Client → Nutrition pillar / digest reflect the meal (description appears in nutritionMeals)
Note: pending estimate expires after 1 hour and is single-use; the photo is never stored
```

**G) Social Drops (Export for Reels, Shorts, TikTok)**
```
Client → GET /v1/orgs/{orgId}/drops → list ready drops (paginated, filter by sourceType, aspectRatio, duration)
Client → Pick dropId from list
Client → GET /v1/orgs/{orgId}/drops/{dropId} → full detail (variants, copy packs)
Client → POST /v1/orgs/{orgId}/drops/{dropId}/export (body: { channelPreset: 'ig_reels' }) → video URL, cover URL, copy pack
Client → Download video, copy caption to clipboard, publish to channel
```

**G2) Social publish schedules (API key)**
```
Client → GET /v1/orgs/{orgId}/social-publish/connected-platforms → subset of platforms with stored account ids
Client → POST /v1/orgs/{orgId}/social-publish → 201, task created as draft; body matches the public OpenAPI create schema
Client → PATCH …/{taskId} with { "status": "active" } → schedule (422 if posting base URL / API key missing or account id missing for any preset platform)
Client → GET /v1/orgs/{orgId}/social-publish?status=active&limit=20 → paginated { data, links }; poll lastRunOutcome / lastErrorSummary after scheduled time
Client → PATCH …/{taskId} with { "status": "draft" } → pause; nextRunAt cleared
Client → DELETE …/{taskId} → 204 soft delete (scheduled runs stop picking it up; an in-flight run continues)
```
OAuth, **`run-now`**, and per-run event listing are **not** on Content API — connect accounts in the **Hyperhuman team app**.

**H2) Organization exercise library**
```
Client → GET /v1/orgs/{orgId}/video-assets → paginated previews + metadata (workspace-only; no stock / pay-as-you-go)
Client → Use for catalogs, builders, playback — detail/audio per locale on video-asset GET when in OpenAPI
```

**H) Exercise Groups (Micro Workouts)**
```
Client → GET /v1/orgs/{orgId}/groups → list micro workouts (paginated, filter by visibility)
Client → Pick groupId from list
Client → GET /v1/orgs/{orgId}/groups/{groupId} → full detail (execution config: circuit/sets, rounds, rest)
Client → GET /v1/orgs/{orgId}/groups/{groupId}/exercises → exercises in collection
Client → Display or play micro workout in app
```

---

## 4) Request Examples

### Workout Generation — minimal
```json
{
  "endUserProfileDetails": {"age": 28, "fitnessLevels": ["beginner"]},
  "exerciseSources": ["premium_stock"],
  "durationOptions": {"minMinutes": 20, "maxMinutes": 20},
  "categoryId": "615aa3f0dbe7140012c59e94"
}
```

### Workout Generation — localized (fr-FR)
```json
{
  "endUserProfileDetails": {"age": 28, "fitnessLevels": ["intermediate"]},
  "exerciseSources": ["premium_stock"],
  "durationOptions": {"minMinutes": 10, "maxMinutes": 15},
  "categoryId": "615aa3f0dbe7140012c59e94",
  "locale": "fr-FR"
}
```

### Workout Generation — with filters (exclude collections)
```json
{
  "endUserProfileDetails": {
    "age": 35,
    "fitnessLevels": ["intermediate"],
    "gender": "female",
    "goalIds": ["63c92926f920a9005c4e619c"],
    "trainingDaysPerWeek": 4,
    "preferredEquipmentCategoryIds": ["67deb36a6a5e49797c122f56"],
    "muscleGroupIds": ["604b0177076fe2727354d743"]
  },
  "exerciseSources": ["free_stock", "premium_stock"],
  "durationOptions": {"minMinutes": 25, "maxMinutes": 30},
  "categoryId": "615aa3f0dbe7140012c59e94",
  "equipment": ["dumbbells", "mat"],
  "excludeExerciseCollections": ["Premium - Neutral"],
  "userRequirements": "CRITICAL: USE up to 3 unique exercises in the whole workout. Focus on one-sided exercises.",
  "locale": "en-GB"
}
```

### Workout Generation — same trainer / location (include collections)
Prefer `includeExerciseCollections` when every clip must share one trainer or filming set. Do **not** simulate this by listing every other collection under `excludeExerciseCollections`.

```json
{
  "endUserProfileDetails": {"age": 30, "fitnessLevels": ["intermediate"]},
  "exerciseSources": ["team_exercises"],
  "includeExerciseCollections": ["Coach Mia - Loft"],
  "durationOptions": {"minMinutes": 20, "maxMinutes": 30},
  "categoryId": "615aa3f0dbe7140012c59e94"
}
```

**Exercise collection filters (generate):**
| Mode | Param | Use when |
|---|---|---|
| Include (allowlist) | `includeExerciseCollections` | Lock trainer, location, or brand look — AI only picks from these `collectionName` values |
| Exclude (denylist) | `excludeExerciseCollections` | Most of a source tier is fine; drop a few unwanted looks |
| Both non-empty | — | **400** — mutually exclusive |
| Pool after filters &lt; 10 exercises (including **0**) | — | **400** — widen collections, sources, or category. Not HTTP 500. Discover names via `GET /v1/workouts/exercises/metadata` (Content API; not the Ultra `stock-exercises` catalog SKU). |
| Team-only / premium-only / team+premium with **0** matching clips | — | **400** — add a stock source or publish team exercises. Same copy as workout adapt. The server does **not** silently use free stock or a template workout. Not retry-able. |
| Omit / empty `exerciseSources` | — | Defaults to **free + premium** (unconstrained). A timeout on the AI step may still recover via a template workout. |

Compose with `exerciseSources` (AND). Discover names/counts: `GET /v1/workouts/exercises/metadata`. `premium_stock` generate is Content API only; `GET /v1/orgs/{id}/stock-exercises` is a separate paid browse SKU.

A collection (`Premium - Advanced Gym`, `Premium - Neutral`) is a **look / filming set**, not an equipment gym. Machine vs free-weight filtering is `preferredEquipmentCategoryIds` / `GET /v1/workouts/equipment/metadata` (Bodyweight, Free Weights, Gym Equipment). `userRequirements` that mention “machines” do not filter the pool. Ultra yearly unlocks catalog **browse of the same clips**, not new machine videos.

**All Optional Fields:**
- `endUserProfileDetails.age` (number) - User age
- `endUserProfileDetails.fitnessLevels` (array) - `["beginner"]`, `["intermediate"]`, `["advanced"]`, or combinations
- `endUserProfileDetails.gender` (string) - `"male"` or `"female"`
- `endUserProfileDetails.goalIds` (array) - Array of goal IDs from `/v1/plans/metadata`
- `endUserProfileDetails.trainingDaysPerWeek` (number) - 1-7
- `endUserProfileDetails.preferredEquipmentCategoryIds` (array) - Equipment category IDs from `/v1/workouts/equipment/metadata`
- `endUserProfileDetails.muscleGroupIds` (array) - Muscle group IDs from `/v1/workouts/exercises/metadata`
- `exerciseSources` (array) - `["premium_stock"]`, `["free_stock"]`, `["team_exercises"]`, or combinations. Constrained empty (no free, 0 clips) → **400**. Omit/empty → free+premium.
- `durationOptions.minMinutes` (number) - Minimum workout duration
- `durationOptions.maxMinutes` (number) - Maximum workout duration
- `categoryId` (string) - Required: Category ID from `/v1/workouts/metadata`
- `equipment` (array) - Equipment names (e.g., `["dumbbells", "mat"]`)
- `includeExerciseCollections` (array) - Collection names to use as the **only** allowed pool (case-insensitive). Mutually exclusive with `excludeExerciseCollections`. Prefer for same trainer/location.
- `excludeExerciseCollections` (array) - Collection names to exclude (case-insensitive). Mutually exclusive with `includeExerciseCollections`.
- `userRequirements` (string) - Free-text instructions for AI. Generate-only name — do **not** send `userGuidance` (adapt-only). Unknown keys → `400` `ValidationError`. Wins over methodology **text** for this request.
- `methodologyProfileId` (string) - Optional 24-character hex id from `GET /v1/orgs/$ORG/methodologies`. Opt-in team Programming DNA. Omit = none applied. Unknown, personal, or inactive ids return **400**. Omit still means none.
- `locale` (string) - Optional BCP-47 locale (e.g. `"fr-FR"`, `"de-DE"`, `"en-GB"`). Localizes AI workout title and read-side exercise metadata. Omit for English-only. Unsupported values return `400`.

### Plan Generation — minimal (no persistence / no top-level id)
```json
{
  "goalIds": ["63c92926f920a9005c4e619c"],
  "workoutCollectionSources": ["template"],
  "durationOptions": {"minWeeks": 4, "maxWeeks": 4},
  "workoutsPerWeek": 2,
  "difficulty": "intermediate"
}
```

### Plan Generation — persisted id (include endUserId)
```json
{
  "goalIds": ["63c92926f920a9005c4e619c"],
  "workoutCollectionSources": ["template"],
  "durationOptions": {"minWeeks": 4, "maxWeeks": 4},
  "workoutsPerWeek": 2,
  "difficulty": "intermediate",
  "endUserProfileDetails": {
    "age": 30,
    "fitnessLevels": ["beginner"],
    "endUserId": "64e3bca9d1b7e600141a9712"
  }
}
```

### Plan Generation — localized (fr-FR)
```json
{
  "goalIds": ["63c92926f920a9005c4e619c"],
  "workoutCollectionSources": ["template"],
  "durationOptions": {"minWeeks": 4, "maxWeeks": 4},
  "workoutsPerWeek": 2,
  "difficulty": "intermediate",
  "locale": "fr-FR"
}
```

### Plan Generation — full profile
```json
{
  "endUserProfileDetails": {
    "age": 32,
    "fitnessLevels": ["intermediate"],
    "gender": "male",
    "goalIds": ["63c92926f920a9005c4e619c"],
    "trainingDaysPerWeek": 4,
    "preferredEquipmentCategoryIds": ["67deb36a6a5e49797c122f56"]
  },
  "workoutCollectionSources": ["template"],
  "durationOptions": {"minWeeks": 4, "maxWeeks": 4},
  "workoutsPerWeek": 3,
  "difficulty": "beginner",
  "userRequirements": "Critical: select and use only workouts that are named and contain the word 'minimal'.",
  "locale": "en-US"
}
```

**Fields:**
- `endUserProfileDetails.endUserId` (string) - Org member hex id. **Required to receive a top-level plan `id`** (persistence). Without it the response has no plan id.
- `endUserProfileDetails.age` (number) - User age
- `endUserProfileDetails.fitnessLevels` (array) - `["beginner"]`, `["intermediate"]`, `["advanced"]`, or combinations
- `endUserProfileDetails.gender` (string) - `"male"` or `"female"`
- `endUserProfileDetails.goalIds` (array) - Array of goal IDs from `/v1/plans/metadata`
- `endUserProfileDetails.trainingDaysPerWeek` (number) - 1-7
- `endUserProfileDetails.preferredEquipmentCategoryIds` (array) - Equipment category IDs from `/v1/workouts/equipment/metadata`
- `workoutCollectionSources` (array) - `["template"]`, `["team"]`, or both — **not** `workoutSources` (adapt-only name)
- `categoryIds` (array) - Optional workout category IDs. Restricts the generate pool to **public** workouts that match **any** of these ids (`GET /v1/workouts/metadata`; hex only, no `custom:`). Empty pool or fewer than 3 matching workouts → **`400`** (not `500`).
- `durationOptions.minWeeks` (number) - Minimum program duration (1-52)
- `durationOptions.maxWeeks` (number) - Maximum program duration (1-52)
- `workoutsPerWeek` (number) - Workouts per week (1-7)
- `difficulty` (string) - `"beginner"`, `"intermediate"`, or `"advanced"`
- `userRequirements` (string) - Free-text instructions for AI. Generate-only name — do **not** send `userGuidance` (adapt-only). Unknown keys → `400` `ValidationError`. Wins over methodology **text** for this request.
- `methodologyProfileId` (string) - Optional 24-character hex id from `GET /v1/orgs/$ORG/methodologies`. Opt-in team Programming DNA. Omit = none applied. Unknown, personal, or inactive ids return **400**. Omit still means none.
- `locale` (string) - Optional BCP-47 locale (e.g. `"fr-FR"`, `"de-DE"`, `"en-GB"`). Localizes AI program name/description and nested workout fields. Omit for English-only. Unsupported values return `400`.

### AI Generation Localization (workout + plan)

Optional body field **`locale`** on `POST .../workouts/generate` and `POST .../plans/generate`.

| `locale` | Workout generate | Plan generate |
|----------|------------------|---------------|
| Omitted | English base fields; `nameTranslations` = `{ "en-US": "..." }` | Same + `descriptionTranslations` |
| `fr-FR`, `de-DE`, … | AI adds target locale to `nameTranslations`; `nameLocalized`, `*Localized`, `difficultyLabel` | AI adds target locale to both translation maps; nested `nameLocalized` / `descriptionLocalized`; maps **persisted** only when a plan `id` is returned (`endUserProfileDetails.endUserId` set) |
| `en-US`, `en-GB`, `en-AU` | English only in maps | English only in maps |
| Invalid (e.g. `xx-XX`) | `400` — `Unsupported locale: xx-XX` | Same |

**Integrator rules:**
- Legacy fields (`name`, `description`, `muscleGroups`, `equipment`) stay **English** on the generate response.
- Use additive `nameTranslations` / `descriptionTranslations` and `*Localized` fields for localized UI.
- On **`GET /v1/plans/{id}?locale=...`**, top-level `name` / `description` are already resolved (unlike the generate response) — only when generate returned a persisted `id`.
- `userRequirements` language does not affect output — `locale` does.

### Programming DNA (methodology profiles)

Team coaches publish reusable Programming DNA in Hyperhuman Teams (**Set as team guideline** then **Make default**; **up to 10** team profiles). Personal (coach-only) Style is never listed and never applies to members.

**This API:** organization API keys never apply a team default on their own — `isTeamDefault` on a list item is informational.

**Hyperhuman Club and the Hyperhuman member web app:** when a team default exists, member Generate and Adapt follow that house Programming DNA. Members do not send `methodologyProfileId`. A personal Style is not enough.

```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/methodologies" \
  -H "X-Api-Key: $API_KEY"
```

**200** `{ "items": [ { "id", "name", "version", "isTeamDefault", "workoutCategoryIds", "programGoalIds", "updatedAt", "ruleSummary": [ { "type", "enforcement", "appliesTo" } ] } ] }`. Full guidance text is **not** returned. Empty `items` is success (no published team profiles).

`ruleSummary[].type` values you may see: `duration_lock`, `frequency_lock`, `collection_policy`, `exercise_exclusion`, `movement_requirement`, `rest_range`, `warmup_cooldown`, `variation_level`, `content_source_priority`. `enforcement` is `hard` or `preferred`. `appliesTo` is `workout`, `program`, or `both`.

```bash
curl -s --max-time 120 -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/generate/preflight" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 28, "fitnessLevels": ["beginner"]},
    "exerciseSources": ["premium_stock"],
    "durationOptions": {"minMinutes": 20, "maxMinutes": 20},
    "categoryId": "615aa3f0dbe7140012c59e94",
    "methodologyProfileId": "REPLACE_WITH_PROFILE_ID"
  }'
```

Preflight does **not** create a workout (**1x**). It is a feasibility dry-run (resolved methodology, hard / preferred rules that would apply, clamped request, `infeasible[]`). If `infeasible` is non-empty, change duration / collections / sources or pick another profile before generate. An unsupported `locale` is **400** here, the same as generate — preflight will not report feasible for a body generate would reject. Preflight is **not** the Generation Inspector snapshot — that arrives on generate. Plan generate has **no** preflight on this API. Persisting a plan with `endUserProfileDetails.endUserId` does not apply a personal Style for that member; unknown methodology, an empty pool, and a bad locale stay **400**.

Then send the same `methodologyProfileId` on `POST .../workouts/generate` or `POST .../plans/generate`. Success body may include a **Generation Inspector** snapshot:

- `methodology` — `{ id, name, version, reason }` when a profile was used; otherwise `null`
- `inspection` — `{ methodology, hardRulesApplied, violations, complianceStatus, generationId }`. `generationId` may be **omitted** when generate has no user owner (organization API-key dummy user). Store the rest of the snapshot.

```json
{
  "methodology": {
    "id": "64f1a2b3c4d5e6f7a8b9c0d1",
    "name": "Team Strength DNA",
    "version": 3,
    "reason": "Requested profile"
  },
  "inspection": {
    "methodology": {
      "id": "64f1a2b3c4d5e6f7a8b9c0d1",
      "name": "Team Strength DNA",
      "version": 3,
      "reason": "Requested profile"
    },
    "hardRulesApplied": ["duration_lock", "warmup_cooldown"],
    "violations": [],
    "complianceStatus": "compliant",
    "generationId": "64f1a2b3c4d5e6f7a8b9c0d2"
  }
}
```

`complianceStatus` is `compliant` | `partial` | `not_applicable`. `not_applicable` is typical when no team profile was applied (or no hard rules applied). `hardRulesApplied` lists rule `type` strings. `violations[]` items include `type`, `enforcement`, `status` (`applied` | `violated` | `not_applicable`), and `detail`. Persist `methodology` / `inspection` on your side — they are partner metadata, not playback fields, and they are **not** replayed on later `GET /v1/plans/{id}`.

**Contract:**
- **This API:** opt-in only. Recommend, adapt, and chat do **not** take `methodologyProfileId`.
- Unknown, personal, or inactive ids return **400**. Omit still means none on organization API keys.
- Hyperhuman Club / member web apply the published **team default** without that field. Personal Style never reaches members.
- `userRequirements` wins over methodology **text** for that request.
- Hard structured rules and platform bounds (exercise / workout pool, safety, duration window, structure) cannot be overridden by text.
- Text guidelines are **guidance, not a guarantee**. Hard-rule results in `inspection` are deterministic.
- There is **no** `GET /v1/generations/{id}` — store `inspection` yourself if you need an audit trail (`generationId` may be omitted). Inspector is generate-only on this API.
- Personal (member-only) profiles are not listed and are not addressable.
- Starter templates in Teams are starting points (preferred rules + text). They are not a generate lock and are not exposed as a Content API catalog.

### Plan Generation Response Example
**Successful plan generation with persistence** (`endUserProfileDetails.endUserId` set; live-validated shape with `"locale": "fr-FR"`). Without `endUserId`, the same fields return **except** top-level `id` is omitted.
```json
{
  "id": "507f1f77bcf86cd799439017",
  "name": "4-Week Intermediate General Fitness",
  "nameTranslations": {
    "en-US": "4-Week Intermediate General Fitness",
    "fr-FR": "Forme Générale Intermédiaire 4 Semaines"
  },
  "description": "Improve your endurance, mobility, and core strength through a balanced 4-week progression.",
  "descriptionTranslations": {
    "en-US": "Improve your endurance, mobility, and core strength through a balanced 4-week progression.",
    "fr-FR": "Améliorez votre endurance, votre mobilité et votre gainage grâce à une progression équilibrée sur 4 semaines."
  },
  "difficulty": "intermediate",
  "difficultyLabel": "Intermédiaire",
  "goalIds": ["63c92926f920a9005c4e619c"],
  "workouts": [
    {
      "id": "641da9c2c4c9c000132f2038",
      "name": "Endurance 15 min beginner",
      "nameLocalized": "Endurance 15 min débutant",
      "duration": 900000,
      "difficulty": "beginner"
    },
    {
      "id": "641daa9bc4c9c000132f2b1d",
      "name": "Endurance 30 min intermediate",
      "nameLocalized": "Endurance 30 min intermédiaire",
      "duration": 1800000,
      "difficulty": "intermediate"
    }
  ],
  "durationInWeeks": 4,
  "workoutsPerWeek": 2
}
```

**Response Fields:**
- `id`: Generated plan ID when persisted — **only** if the request included `endUserProfileDetails.endUserId`; otherwise omitted. Store when present.
- `name`: Plan title (canonical English base)
- `nameTranslations`: Per-locale plan name from the AI. Always includes `en-US`; includes the requested locale when non-English. **Additive localization field.**
- `description`: Plan overview (canonical English base)
- `descriptionTranslations`: Per-locale plan description from the AI. Always includes `en-US`. **Additive localization field.**
- `difficulty`: Overall plan difficulty enum (`beginner` / `intermediate` / `advanced`)
- `difficultyLabel`: Difficulty label resolved for the requested locale (silent en-US fallback). **Additive localization field.**
- `goalIds[]`: Array of fitness goal IDs the plan addresses
- `workouts[]`: Array of workouts in the plan (ordered by program sequence on org endpoint)
  - `id`: Workout ID
  - `name`: Workout title (English base)
  - `nameLocalized`: Workout name resolved for the requested locale (silent en-US fallback). **Additive localization field.**
  - `duration`: Duration in milliseconds
  - `difficulty`: Workout difficulty level
  - `description`: Workout focus/description (English base)
  - `descriptionLocalized`: Workout description resolved for the requested locale. **Additive localization field.**
- `durationInWeeks`: Total program duration
- `workoutsPerWeek`: Weekly workout frequency
- `methodology`: Applied team Programming DNA when a profile was used (`{ id, name, version, reason }`); otherwise `null`. **Generation Inspector.**
- `inspection`: Generation Inspector snapshot (`methodology`, `hardRulesApplied`, `violations`, `complianceStatus`, `generationId`). `generationId` may be omitted. Store it yourself — not replayed on later `GET /v1/plans/{id}`.

**Localization notes:**
- Program `name` / `description` are authored in en-US and the target locale within the same generate call. Persisted maps make `GET /v1/plans/{id}?locale=...` localized end-to-end.
- Nested workout `nameLocalized` / `descriptionLocalized` come from stored DB translations; missing translations fall back silently to English.
- Omitting `locale` returns `{ "en-US": ... }` maps only; legacy fields unchanged (backward compatible).
- Unsupported `locale` values return `400 BadRequest` on Content API generate endpoints.
- `en-GB` / `en-AU` reuse en-US wording (no second-language model output).

**Historical pre-localization example** (no `locale` — still valid shape, maps omitted for brevity):
```json
{
  "id": "507f1f77bcf86cd799439017",
  "name": "8-Week Strength Building Program",
  "description": "A comprehensive 8-week program designed to build strength and muscle mass through progressive overload and compound movements.",
  "difficulty": "intermediate",
  "goalIds": ["63c92926f920a9005c4e619d", "63c92926f920a9005c4e619c"],
  "workouts": [
    {
      "id": "507f1f77bcf86cd799439018",
      "name": "Upper Body Strength",
      "duration": 2700000,
      "difficulty": "intermediate",
      "description": "Focus on chest, shoulders, and arms with compound movements"
    }
  ],
  "durationInWeeks": 8,
  "workoutsPerWeek": 3
}
```

### Workout Generation Response Example
**Successful workout generation returns** (live-validated example with `"locale": "fr-FR"`):
```json
{
  "name": "Intermediate Strength Builder",
  "nameTranslations": {
    "en-US": "Intermediate Strength Builder",
    "fr-FR": "Renforcement Musculaire Intermédiaire"
  },
  "instances": [
    {
      "id": "64f1a2b3c4d5e6f7a8b9c0d1",
      "kind": "single-exercise",
      "durationSeconds": 30,
      "exercise": {
        "id": "6a2d0e568d5154001ac3ede8",
        "name": "Lateral Hip Opener",
        "nameLocalized": "Lateral Hip Opener",
        "muscleGroups": ["Legs", "Glutes"],
        "muscleGroupsLocalized": [
          { "id": "604b0177076fe2727354d743", "name": "Jambes" },
          { "id": "604b0173076fe2727354d742", "name": "fessiers" }
        ]
      }
    }
  ],
  "totalDurationSeconds": 660,
  "difficulty": "intermediate",
  "difficultyLabel": "Intermédiaire",
  "categoryId": "615aa3f0dbe7140012c59e94"
}
```

**Response Fields:**
- `name`: Generated workout title (canonical English / en-US base)
- `nameTranslations`: Per-locale workout name produced by the AI. Always includes `en-US`; includes the requested locale when it is non-English. **Additive localization field.**
- `instances[]`: Ordered array of exercise and rest segments
  - `kind`: `"single-exercise"` or `"rest"`
  - `durationSeconds`/`reps`: Planned work window (seconds) or repetition count — **not** the MP4 file length. Loop the clip from `GET /v1/video-asset/{exercise.id}` until that clock elapses when `playsInLoop` is true (default)
  - `pace`/`weightLevel`/`skipIntro`/`weight`/`weightUnit`: timing/intensity metadata when applicable
  - `exercise`: Exercise metadata (only for exercise instances): `id`, `name` (en-US base), `nameLocalized` (resolved for the requested locale), `thumbnailUrl`, `videoUri`, `estimatedRepDuration`, `muscleGroups` (en-US names), `muscleGroupsLocalized` (`{ id, name }[]`), `equipment` (en-US names), `equipmentLocalized` (`{ id, name }[]`)
- `totalDurationSeconds`: Total workout duration
- `difficulty`: Difficulty key echoed from the request (e.g. `"intermediate"`)
- `difficultyLabel`: Difficulty label resolved for the requested locale (silent en-US fallback). **Additive localization field.**
- `categoryId`: Workout category ID
- `category`: `{ id, name }` with the locale-resolved category name; present when the category is resolvable. **Additive localization field.**
- `methodology`: Applied team Programming DNA when a profile was used (`{ id, name, version, reason }`); otherwise `null`. **Generation Inspector.**
- `inspection`: Generation Inspector snapshot (`methodology`, `hardRulesApplied`, `violations`, `complianceStatus`, `generationId`). `generationId` may be omitted. Store it yourself — there is no GET-by-generation-id. Not a playback field.

**Playback (critical):** There is **no top-level persisted workout `id`**. The result is **not** in `GET /v1/orgs/{orgId}/workouts`. Play via the **JSON-receiver iframe** (`HYPERHUMAN_EMBED_INIT` with this payload — media via `GET /v1/video-asset/{exerciseId}`) or a custom player. Put the member `locale` on the iframe URL; the player forwards it onto each video-asset GET. Omitted `locale` is `en-US` (one language of instruction and cue audio). Body `locale` on this POST localizes names only. Do **not** call `/playlist`, `/music`, or sessions/feedback for this response. PulseMix + sessions need a persisted id (pre-built embed). Prefer `POST .../workouts/recommend` when you want a curated library workout instead.

**Localization notes:**
- Resolved localized fields (`nameLocalized`, `muscleGroupsLocalized`, `equipmentLocalized`, `difficultyLabel`, `category.name`) carry the value for the requested locale resolved through the locale chain (requested locale -> language -> `en-US` -> base), matching the workout playlist read-side convention. Legacy `name` / `muscleGroups` / `equipment` fields stay English, so existing integrations are unaffected.
- The workout name is generated by the AI directly in the target locale and `en-US` (no machine translation) and is the one field returned as a per-locale map (`nameTranslations`), so clients always have both the default and localized title. `en-GB` / `en-AU` reuse `en-US` wording.
- Exercise / muscle group / equipment names come from stored translations; missing translations fall back silently to `en-US`. The exercise selection itself is locale-independent.

### Workout Adaptation
```bash
curl -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/$WID/adapt" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endUserProfileId": "64e3bca9d1b7e600141a9712",
    "endUserProfileDetails": {
      "age": 35,
      "gender": "female",
      "fitnessLevels": ["intermediate"],
      "goalIds": ["63c92926f920a9005c4e619c"],
      "trainingDaysPerWeek": 4,
      "preferredEquipmentCategoryIds": ["67deb36a6a5e49797c122f56"]
    },
    "exerciseSources": ["premium_stock"],
    "userGuidance": "I have a knee injury. Please remove high-impact exercises."
  }'
```

> `{workoutId}` must belong to `{orgId}`. Adapting a workout that is not part of your organization returns **`404 Not Found`**. Response is **ephemeral JSON** (same family as workout generate: `name`, `instances[]`, … — **no** top-level persisted workout `id`). Play via JSON receiver / custom player; do not call `/playlist` or `/music` for this response.

**All Optional Fields:**
- `endUserProfileId` (string) - Internal user ID for accessing 60-day wearable data
- `endUserProfileDetails.age` (number) - User age
- `endUserProfileDetails.gender` (string) - `"male"` or `"female"`
- `endUserProfileDetails.fitnessLevels` (array) - Fitness levels
- `endUserProfileDetails.goalIds` (array) - Goal IDs
- `endUserProfileDetails.trainingDaysPerWeek` (number) - Training frequency
- `endUserProfileDetails.preferredEquipmentCategoryIds` (array) - Equipment preferences
- `exerciseSources` (array) - `["premium_stock"]`, `["free_stock"]`, or both
- `userGuidance` (string) - Free-text instructions (injuries, preferences, constraints). Adapt-only name — do **not** send `userRequirements` (generate-only).

### Plan Adaptation
```bash
curl -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans/$PID/adapt" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endUserProfileId": "64e3bca9d1b7e600141a9712",
    "endUserProfileDetails": {
      "age": 28,
      "gender": "male",
      "fitnessLevels": ["beginner"],
      "goalIds": ["63c92926f920a9005c4e619c"],
      "trainingDaysPerWeek": 3,
      "preferredEquipmentCategoryIds": ["67deb36a6a5e49797c122f56"]
    },
    "workoutSources": ["template", "team"],
    "userGuidance": "I only have time for 3 workouts per week now. Please remove workouts to fit my schedule."
  }'
```

> `{planId}` must belong to `{orgId}` (org-stamped `_teamId`, trainer catalog, or an `end-user-ai-generated` plan owned by an active org member — including programs from `POST .../plans/generate`). Other-org or non-member plans return **`404 Not Found`** (not 403). **`endUserProfileId` is required** (omit → **`400`**). Response includes a **persisted** adapted program `id` — store it and retrieve with `GET /v1/plans/{id}` (unlike workout adapt, which is ephemeral JSON). Active enrollment for that user → next workout slot only. Else full future schedule **in place** for freshly generated plans; catalog-derived user copies (`originalProgramId`) that are not active → **`400`** (start the plan first).

**Fields:**
- `endUserProfileId` (string, **required**) - Org member hex id (persist owner + context)
- `endUserProfileDetails.age` (number) - User age
- `endUserProfileDetails.gender` (string) - `"male"` or `"female"`
- `endUserProfileDetails.fitnessLevels` (array) - Fitness levels
- `endUserProfileDetails.goalIds` (array) - Goal IDs
- `endUserProfileDetails.trainingDaysPerWeek` (number) - Training frequency
- `endUserProfileDetails.preferredEquipmentCategoryIds` (array) - Equipment preferences
- `workoutSources` (array, **required**) - `["template"]`, `["team"]`, or both — **not** `workoutCollectionSources`
- `userGuidance` (string) - Free-text instructions (injuries, schedule changes, preferences). Adapt-only name — do **not** send `userRequirements` (generate-only).

### Session Lifecycle
```bash
# Start session
curl -s -X POST "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/start" \
  -H "X-Api-Key: $API_KEY"
# Returns: { data: { id, status: "started", duration: 0, progress: 0, ... } }
# Unknown workoutId → 404 and no session is created.
# Active program: add JSON body { "programPosition": <positionInPlan> }. Same field on end and complete.
# Closes older in-progress sessions for this workout. They keep their own progress and any
# programPosition already stored. They do not receive this one. At progress >= 0.1 that stored
# slot is pinned when it still matches; otherwise the earliest open copy is filled.

# Update progress (progressSeconds is recommended for accurate duration tracking)
# Basic progress update (recommended - tracks actual playback time)
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/$SID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"progressSeconds": 125.5}'

# Full progress update (with segment tracking - optional)
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/$SID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"workoutSegmentId":"seg-1", "progressSeconds": 125.5}'

# Get session by ID
curl -s "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/$SID" -H "X-Api-Key: $API_KEY"
# Returns: { data: { id, status, duration, progress, workoutSegmentId, ... } }

# End session (preserves duration from last PATCH; body required)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/end" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"sessionId":"'$SID'"}'

# Complete session (force 100% completion with full workout duration; body required)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/complete" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"sessionId":"'$SID'"}'

# Same call when this workout is slot 2 of the active program (positionInPlan).
# Omit programPosition and the earliest open matching slot is filled (a skipped copy only when no open copy is left).
curl -s -X POST "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/complete" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"sessionId":"'$SID'","programPosition":2}'
```

### Session Duration Tracking (Best Practices)
```bash
# progressSeconds is the PRIMARY duration tracking method (RECOMMENDED for accurate tracking)
# Send progressSeconds = actual playback time in seconds (excluding pauses). Must be >= 0 (negative → 400).
# The session must belong to this workoutId and the same trainee. A mismatch is 404, before a completed 409.
# workoutSegmentId is OPTIONAL - use to track current segment position
# PATCH every 10-30 seconds during active playback
# PATCH on segment transitions
# Send final PATCH before calling end session

# Basic tracking (recommended - just progress)
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/$SID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"progressSeconds": 125.5}'

# Full tracking (progress + segment position)
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/workouts/$WID/sessions/$SID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"workoutSegmentId":"seg-exercise-2", "progressSeconds": 125.5}'

# Duration is capped at 2x expected workout duration to prevent outliers
```

### Workout Recommendations (Fast Discovery)
```bash
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/recommend" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {
      "age": 28,
      "fitnessLevels": ["beginner"],
      "goalIds": ["63c92926f920a9005c4e619c"],
      "preferredEquipmentCategoryIds": ["67deb36a6a5e49797c122f56"]
    }
  }'
```

**Request body (whitelisted — extra keys return `400 ValidationError`):**
- `endUserProfileDetails` (object) — all personalization signals go **inside** this object: `age`, `gender` (`male`/`female`/`other`), `fitnessLevels`, `goalIds`, `trainingDaysPerWeek`, `preferredEquipmentCategoryIds`, `endUserId`.
- `categoryIds` (string[]) — optional. Restricts the generate pool to **public** team/template workouts that match **any** of these ids. Hex ids only (not list `custom:` tokens). Use `GET /v1/workouts/metadata` for ids.
- `locale` (string) — BCP-47 locale for recommendation text.

There is no top-level `goalIds`, `equipment`, `durationOptions`, or `limit` for workout recommend; results are always capped at 12. Pool is always **public** only — set a workout to `private` to retire it from recommend while `GET /v1/workouts/{id}` still plays.

### Workout Music Playlist
PulseMix tracks for a **persisted** workout id only:

```bash
curl -s "https://content.api.hyperhuman.cc/v1/workouts/$WID/music" -H "X-Api-Key: $API_KEY"
```

**Returns:** Bare `{ tracks, totalDuration, workoutDuration }` with pre-signed URLs (~7 days). Play `tracks[].url` **verbatim** (no API key on the file). Empty pool → `tracks: []`. The JSON-receiver embed does **not** call this endpoint; `mixBgMusic` on a posted generate JSON has no effect.

### Video Export Stream
```bash
curl -s "https://content.api.hyperhuman.cc/v1/workouts/$WID/export/video/stream_url?locale=en-US" \
  -H "X-Api-Key: $API_KEY"
```

**Returns:** `200` / `text/plain` — one presigned HTTPS URL (valid ~7 days). Play it **verbatim** (same SigV4 rules as playlist `video.url`). The linked file is the latest ready full video for that locale (commonly MP4; format follows the URL and object). A **404** is JSON `{ "error": ... }` with `Content-Type: application/json` — do not parse it as a URL. The audio route below follows the same split.

A full video exists after Generate in Teams, or after a create/update by a coach who has **Full workout video** on under Workout Defaults → Format (off by default). Turning it on does not render workouts already saved. With it off, a studio save removes every locale and this route stays `404`. With it on, the save renders the coach's language and that locale's previous ready file is returned until the new render replaces it. A failed render leaves the previous file. Other locales stay until a later save with the switch off, or a Generate for that locale. A first render often takes tens of minutes, plus queue time. A `200` during that window is the previous file. Re-fetch this route; a URL already fetched stays valid until it expires. Poll on the order of minutes. There is no Content API to start a render or read render status. Omit `locale` for the latest ready file in any locale; a provided locale is exact or `404`. `GET .../playlist` is not removed by the save.

### Audio Export Stream
```bash
curl -s "https://content.api.hyperhuman.cc/v1/workouts/$WID/export/audio/stream_url?locale=en-US" \
  -H "X-Api-Key: $API_KEY"
```

**Returns:** `200` / `text/plain` — one presigned HTTPS URL to the narrative audio asset (valid ~7 days; commonly M4A).

## 5) Exports vs Playlists (Choose Your Playback Method)

| Scenario | Use Exports | Use Playlist |
|----------|-------------|--------------|
| **Offline Playback** | ✅ Complete file download | ❌ Requires internet |
| **Custom Player Controls** | ❌ Limited controls | ✅ Full segment control |
| **Simple Integration** | ✅ Standard HTML5 video/audio | ❌ Complex segment management |
| **File Size** | ⚠️ Large files (100-500MB video) | ✅ Per-exercise progressive MP4 clips |
| **Skip/Navigation** | ❌ No segment skipping | ✅ Jump between exercises |
| **Class chapter navigation** | ❌ Not in export | ✅ Via `class.chapters` + seek on `main.video` (progressive MP4) |
| **Progress Tracking** | ⚠️ Manual implementation | ✅ Built-in session tracking |
| **Background Audio** | ✅ Perfect for audio-only | ❌ Audio mixed with video |

**Export Use Cases:**
- Offline video/audio downloads
- Social media sharing
- Third-party video players
- Simple web/mobile playback
- Content archival

**Playlist Use Cases:**
- Interactive workout players
- Progress tracking
- Exercise skipping
- Custom UI controls
- Real-time coaching

### HTML5 Video Export Integration
```html
<!-- Get video URL from API -->
<script>
async function loadWorkoutVideo(workoutId) {
  const response = await fetch(`/v1/workouts/${workoutId}/export/video/stream_url`, {
    headers: { 'X-Api-Key': 'YOUR_API_KEY' }
  });
  const videoUrl = await response.text();
  
  const video = document.getElementById('workout-video');
  video.src = videoUrl;
}
</script>

<video id="workout-video" controls width="1920" height="1080">
  <source type="video/mp4">
  Your browser does not support the video tag.
</video>
```

### HTML5 Audio Export Integration
```html
<!-- Get audio URL from API -->
<script>
async function loadWorkoutAudio(workoutId) {
  const response = await fetch(`/v1/workouts/${workoutId}/export/audio/stream_url`, {
    headers: { 'X-Api-Key': 'YOUR_API_KEY' }
  });
  const audioUrl = await response.text();
  
  const audio = document.getElementById('workout-audio');
  audio.src = audioUrl;
  
  // Enable background playback
  if ('mediaSession' in navigator) {
    navigator.mediaSession.metadata = new MediaMetadata({
      title: 'Workout Audio',
      artist: 'Hyperhuman',
      album: 'Fitness Content'
    });
  }
}
</script>

<audio id="workout-audio" controls>
  <source type="audio/mp4">
  Your browser does not support the audio tag.
</audio>
```

### Download Implementation
```javascript
async function downloadWorkoutVideo(workoutId, filename = 'workout.mp4') {
  const response = await fetch(`/v1/workouts/${workoutId}/export/video/stream_url`, {
    headers: { 'X-Api-Key': 'YOUR_API_KEY' }
  });
  const videoUrl = await response.text();
  
  // Create download link
  const link = document.createElement('a');
  link.href = videoUrl;
  link.download = filename;
  link.style.display = 'none';
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
}
```

### Plan Recommendations (Fast Discovery)
```bash
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans/recommend" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 28, "fitnessLevels": ["beginner"]},
    "goalIds": ["63c92926f920a9005c4e619c"]
  }'
```

**Request body (whitelisted — extra keys return `400 ValidationError`):**
- `endUserProfileDetails` (object) — personalization signals: `age`, `gender`, `fitnessLevels`, `goalIds`, `trainingDaysPerWeek`, `preferredEquipmentCategoryIds`, `endUserId`.
- `goalIds` (string[]) — optional goal ids at the **root** of the body (merged with `endUserProfileDetails.goalIds` for library matching).
- `locale` (string) — BCP-47 locale for recommendation text.

There is no top-level `categoryId`, `durationInWeeks`, `workoutsPerWeek`, or `limit` for plan recommend; results are always capped at 12.

### Feedback (Correct Format)
```bash
# Get feedback options first — wire shape: { data: { rating: [{ id, label, value }], difficulty: [...] } }
curl -s "https://content.api.hyperhuman.cc/v1/workouts/feedback/options" -H "X-Api-Key: $API_KEY"

# Submit feedback with option IDs from the options response (not numeric values).
# ratingId must be one of data.rating[].id; difficultyId must be one of data.difficulty[].id.
# Swapping the lists is 400 BadRequest. A non-hex id is 400 ValidationError.
curl -s -X POST "https://content.api.hyperhuman.cc/v1/workouts/$WID/feedback" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "workoutSessionId": "12ab3efd45c67c1234566cd1",
    "ratingId": "60af7f9d8ded6000114c92c4",
    "difficultyId": "60af7fb98ded6000114c92c7",
    "comment": "Great coaching and pacing!"
  }'
```

### User Management

#### Batch User Invitation (Up to 100 Users)

Creates **pending** client invites. Hyperhuman may send a **transactional invite email** (organization **Invite emails** setting, default **on**).

Optional body **`sendInviteEmail`** overrides that setting for this batch only:

| `sendInviteEmail` | Organization Invite emails | Hyperhuman invite email |
|-------------------|----------------------------|-------------------------|
| omitted | on (default) | sent for new invites |
| omitted | off | not sent |
| `false` | any | not sent for this batch |
| `true` | any | sent for this batch |

Pending invites are always created. When you email clients yourself, set **`sendInviteEmail: false`** and point users at your organization **Redirect URL**. Updating an existing pending invite does **not** re-send email.

| Field | Where | Meaning |
|-------|--------|---------|
| `sendInviteEmail` | request body (optional) | Per-request override. Omit → organization setting. Request wins when set. |
| `inviteEmailSent` | response | Effective send **policy** for *new* invites in this request — not a per-user delivery receipt |

```bash
# Invite multiple users at once with external IDs for integration
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/invite" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "users": [
      {"email": "john.doe@company.com", "externalUUID": "emp-001"},
      {"email": "jane.smith@company.com", "externalUUID": "emp-002"},
      {"email": "bob.jones@company.com", "externalUUID": "emp-003"},
      {"email": "alice.brown@company.com", "externalUUID": "emp-004"},
      {"email": "charlie.wilson@company.com", "externalUUID": "emp-005"}
    ]
  }'

# Skip Hyperhuman transactional invite email when you send your own
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/invite" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sendInviteEmail": false,
    "users": [
      {"email": "john.doe@company.com", "externalUUID": "emp-001"}
    ]
  }'

# Response (default / sendInviteEmail omitted or true → policy on)
# {
#   "successful": 4,
#   "failed": [
#     {
#       "email": "charlie.wilson@company.com",
#       "reason": "User already exists"
#     }
#   ],
#   "invited": [
#     { "email": "john.doe@company.com" },
#     { "email": "jane.smith@company.com", "endUserId": "507f1f77bcf86cd799439011" }
#   ],
#   "inviteEmailSent": true
# }
#
# With "sendInviteEmail": false → same shape, "inviteEmailSent": false
# (pending invites still created)
```

#### Bulk User Operations with CSV Processing
```javascript
// Process CSV file and batch invite users
async function bulkInviteUsers(csvData, apiKey, orgId) {
  const users = parseCSV(csvData); // Parse email,externalUUID,name fields
  const batches = chunkArray(users, 100); // Split into batches of 100

  const results = {
    totalProcessed: 0,
    totalSuccessful: 0,
    totalFailed: 0,
    errors: []
  };

  for (const batch of batches) {
    try {
      const response = await fetch(
        `https://content.api.hyperhuman.cc/v1/orgs/${orgId}/endusers/invite`,
        {
          method: 'POST',
          headers: {
            'X-Api-Key': apiKey,
            'Content-Type': 'application/json'
          },
          // Optional: sendInviteEmail: false when the partner emails clients themselves
          body: JSON.stringify({ users: batch })
        }
      );

      const result = await response.json();
      // result.inviteEmailSent = batch send policy for *new* invites (not delivery receipts)

      results.totalProcessed += batch.length;
      results.totalSuccessful += result.successful;
      results.totalFailed += result.failed.length;

      if (result.failed.length > 0) {
        results.errors.push(...result.failed);
      }

      // Respect rate limits
      await new Promise(resolve => setTimeout(resolve, 1000));

    } catch (error) {
      console.error('Batch invite failed:', error);
      results.errors.push({
        batch: batch.length,
        error: error.message
      });
    }
  }

  return results;
}

// Helper function to chunk array into batches
function chunkArray(array, size) {
  const chunks = [];
  for (let i = 0; i < array.length; i += size) {
    chunks.push(array.slice(i, i + size));
  }
  return chunks;
}
```

# Check if user exists (by email OR externalUUID)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/find?email=$EMAIL" -H "X-Api-Key: $API_KEY"
# Returns a single user object (200) or 404 "End user not found in this organization".
# A malformed email returns 400 ValidationError.
# {
#   "email": "user@example.com",
#   "firstName": "John",
#   "lastName": "Doe",
#   "externalUUID": "ext-user-123",   # present only when set on the user
#   "joinedDate": "2025-09-15T10:30:00Z",
#   "status": "active",
#   "endUserId": "6821e6ad9d20276ef3c3c9ec"  # internal 24-hex id; present only when the user has an app profile.
#                                            # Use this value as {endUserId} in insights/plans/nutrition paths.
# }

# List all users (with filtering and sorting)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers?limit=20&offset=0&status=active&sortBy=joined&sortDirection=desc" -H "X-Api-Key: $API_KEY"

# Query parameters for user list:
# limit: 1-100 (default: 20) - Number of users per page
# offset: >= 0 (default: 0) - Pagination offset (NOT page-based, uses offset)
# status: "active", "suspended", or "all" (default: all) - Filter by user status
# sortBy: "name", "status", or "joined" (default: joined) - Sort field
# sortDirection: "asc" or "desc" (default: asc) - Sort direction

# Note: This endpoint uses OFFSET-based pagination, not page-based.
# To get page 2 with 20 items per page: offset=20 (not page=2)

# Returns (externalUUID is omitted for users without an external id):
# {
#   "data": [
#     {
#       "email": "user@example.com",
#       "firstName": "John",
#       "lastName": "Doe",
#       "externalUUID": "ext-user-123",
#       "joinedDate": "2025-09-15T10:30:00Z",
#       "status": "active"
#     }
#   ],
#   "total": 1
# }

# Suspend user (body: email OR externalUUID)
curl -s -X PUT "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/suspend" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

# Reactivate user (body: email OR externalUUID)
curl -s -X PUT "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/reactivate" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

# Remove user permanently (body: email OR externalUUID; one required)
curl -s -X DELETE "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'
# Or by externalUUID: -d '{"externalUUID": "ext-user-123"}'
```

**User identifier:** Suspend, reactivate, and delete accept `email` or `externalUUID` in the body (provide one).

#### Bulk Status Management
```javascript
// Bulk suspend multiple users (e.g., after company policy violation)
async function bulkSuspendUsers(userEmails, apiKey, orgId) {
  const results = { successful: [], failed: [] };

  for (const email of userEmails) {
    try {
      const response = await fetch(
        `https://content.api.hyperhuman.cc/v1/orgs/${orgId}/endusers/suspend`,
        {
          method: 'PUT',
          headers: {
            'X-Api-Key': apiKey,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ email })
        }
      );

      if (response.ok) {
        results.successful.push(email);
      } else {
        const error = await response.json();
        results.failed.push({ email, reason: error.message });
      }

      // Small delay to respect rate limits
      await new Promise(resolve => setTimeout(resolve, 200));

    } catch (error) {
      results.failed.push({ email, reason: error.message });
    }
  }

  return results;
}

// Bulk reactivate users (e.g., after resolving issues)
async function bulkReactivateUsers(userEmails, apiKey, orgId) {
  // Similar implementation to bulkSuspendUsers but using /reactivate endpoint
  return bulkStatusChange(userEmails, apiKey, orgId, 'reactivate');
}

// Bulk remove users (permanent deletion - use with caution!)
async function bulkRemoveUsers(userEmails, apiKey, orgId) {
  const results = { successful: [], failed: [] };

  for (const email of userEmails) {
    try {
      // First verify user exists
      const checkResponse = await fetch(
        `https://content.api.hyperhuman.cc/v1/orgs/${orgId}/endusers/find?email=${encodeURIComponent(email)}`,
        { headers: { 'X-Api-Key': apiKey } }
      );

      if (!checkResponse.ok) {
        results.failed.push({ email, reason: 'User not found' });
        continue;
      }

      // Proceed with deletion
      const deleteResponse = await fetch(
        `https://content.api.hyperhuman.cc/v1/orgs/${orgId}/endusers`,
        {
          method: 'DELETE',
          headers: {
            'X-Api-Key': apiKey,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ email })
        }
      );

      if (deleteResponse.ok) {
        results.successful.push(email);
      } else {
        const error = await deleteResponse.json();
        results.failed.push({ email, reason: error.message });
      }

      // Longer delay for destructive operations
      await new Promise(resolve => setTimeout(resolve, 500));

    } catch (error) {
      results.failed.push({ email, reason: error.message });
    }
  }

  return results;
}
```

#### User Synchronization with External Systems
```javascript
// Sync user status changes from HR system
async function syncUserStatusChanges(changes, apiKey, orgId) {
  const results = { suspended: [], reactivated: [], errors: [] };

  for (const change of changes) {
    try {
      const endpoint = change.action === 'suspend' ? 'suspend' : 'reactivate';

      const response = await fetch(
        `https://content.api.hyperhuman.cc/v1/orgs/${orgId}/endusers/${endpoint}`,
        {
          method: 'PUT',
          headers: {
            'X-Api-Key': apiKey,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ email: change.email })
        }
      );

      if (response.ok) {
        if (change.action === 'suspend') {
          results.suspended.push(change.email);
        } else {
          results.reactivated.push(change.email);
        }
      } else {
        results.errors.push({
          email: change.email,
          action: change.action,
          reason: (await response.json()).message
        });
      }

    } catch (error) {
      results.errors.push({
        email: change.email,
        action: change.action,
        reason: error.message
      });
    }
  }

  return results;
}
```

### Social Drops
```bash
# List ready drops (paginated)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/drops?limit=20&offset=0" -H "X-Api-Key: $API_KEY"

# List with filters (sourceType, aspectRatio, duration, search)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/drops?sourceType=exercise&aspectRatio=9:16&duration=30&q=HIIT&sort=-updatedAt" -H "X-Api-Key: $API_KEY"

# Get drop detail (variants, copy packs)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/drops/$DID" -H "X-Api-Key: $API_KEY"

# Export for channel (video URL, cover, copy pack)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/drops/$DID/export" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"channelPreset": "ig_reels"}'
```

**Export response:** `{ videoDownloadLink, coverLink, copyPack: { hook, caption, hashtags, cta, pinnedComment, posterTagline } }`

**List query parameters:**
- `limit` (number, default: 20, max: 50) - Items per page
- `offset` (number, default: 0) - Pagination offset
- `q` (string, min 3 chars) - Search by drop title
- `sort` (string) - Sort: `+updatedAt`, `-createdAt`, `+createdAt`, `-updatedAt`
- `sourceType` (string) - Filter: `exercise`, `exerciseCollection`, `workout`, `workoutCollection`
- `sourceId` (string) - Filter by source entity ID
- `aspectRatio` (string) - Filter: `9:16`, `1:1`, `16:9`
- `duration` (number) - Filter by duration in seconds: `15`, `30`, `45`, `60`

**Export body (optional):**
- `channelPreset` (string) - `ig_reels`, `ig_stories`, `ig_carousel`, `tiktok`, `yt_shorts`, `fb_reels`, `yt_longform`
- `aspectRatio` (string) - Override: `9:16`, `1:1`, `16:9`
- `duration` (number) - Override in seconds: `15`, `30`, `45`, `60`

**Note:** Only drops with status `ready` are returned. Presigned URLs are valid for 7 days.

### Social publish (scheduled automations)
```bash
# Connected platforms (instagram | tiktok | youtube | facebook with non-empty stored id)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish/connected-platforms" -H "X-Api-Key: $API_KEY"

# List with filters (optional: status, sourceMode, sortBy, sortOrder, search)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish?limit=20&offset=0&status=active" -H "X-Api-Key: $API_KEY"

# Create draft — request body matches the public OpenAPI create schema
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Weekly Reels","sourceMode":"manual_fixed","dropId":"'$DID'","primaryChannelPreset":"ig_reels","channelPresets":["ig_reels"],"repeatMode":"weekly"}'

# Get one (includes lastRunOutcome, lastErrorSummary, lastExecutionAt when events exist)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish/$TASK_ID" -H "X-Api-Key: $API_KEY"

# Activate — 422 if social publishing is not configured or accountId missing for a platform required by channelPresets
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish/$TASK_ID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"status":"active"}'

# Pause (clears nextRunAt)
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish/$TASK_ID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"status":"draft"}'

# Reschedule active task (Content Autopilot–style) — PATCH nextRunAt ISO string only
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish/$TASK_ID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"nextRunAt":"2026-04-15T14:00:00.000Z"}'

# Soft delete
curl -s -o /dev/null -w "%{http_code}" -X DELETE "https://content.api.hyperhuman.cc/v1/orgs/$ORG/social-publish/$TASK_ID" -H "X-Api-Key: $API_KEY"
```

**Learnings:** (1) **Create** returns **403** if the team owner has **no** remaining monthly social export capacity (same billing user as org drop export). (2) Quota is always billed against the **team owner**. (3) **`lastRun*`** reflect the **latest** event only — poll **GET task** after the slot time; there is no **`run-now`** on Content API. (4) **Removed:** **`/v1/orgs/.../social-weekly-plan`** (planner only, never executed posts).

### Content Autopilot (scheduled content automations)
Recurring jobs that **generate**, **clone**, or **publish** content on a schedule. Tasks are created as `draft` and only run once `active`. All routes require the API key to belong to `{orgId}`.

```bash
# List automations (filters optional)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot?limit=20&status=active&scope=ai_generate&contentType=workout&sortBy=updatedAt&sortOrder=descend" \
  -H "X-Api-Key: $API_KEY"

# Create automation (draft). Required: name, scope, contentType, difficultyOptions, repeatMode
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Daily Beginner Workout",
    "scope": "ai_generate",
    "contentType": "workout",
    "difficultyOptions": ["beginner"],
    "categoryIds": ["615aa3f0dbe7140012c59e94"],
    "durationMin": 20,
    "durationMax": 30,
    "includeFreeStock": true,
    "repeatMode": "daily",
    "promptGuidanceMode": "none"
  }'

# Get / update / soft-delete a task
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/$TASK_ID" -H "X-Api-Key: $API_KEY"
curl -s -X PATCH "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/$TASK_ID" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" -d '{"durationMax": 45}'
curl -s -o /dev/null -w "%{http_code}" -X DELETE "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/$TASK_ID" -H "X-Api-Key: $API_KEY"

# Activate / deactivate / trigger an immediate run
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/$TASK_ID/activate"   -H "X-Api-Key: $API_KEY"
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/$TASK_ID/deactivate" -H "X-Api-Key: $API_KEY"
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/$TASK_ID/run-now"    -H "X-Api-Key: $API_KEY"

# Execution events (org-wide or per task; filter by eventType)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/events?limit=20&eventType=success" -H "X-Api-Key: $API_KEY"
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/content-autopilot/$TASK_ID/events?limit=20" -H "X-Api-Key: $API_KEY"
```

**Key request fields (create body):**
- `name` (required), `scope` (required: `ai_generate` | `create_similar` | `publish_existing`), `contentType` (required: `workout` | `program`).
- `difficultyOptions` (required: array of `beginner` | `intermediate` | `advanced`), `repeatMode` (required: `once` | `daily` | `weekly` | `biweekly` | `monthly`).
- `sourceContentId` (for `create_similar`), `existingContentId` (for `publish_existing`).
- Library filters: `categoryIds`, `goalIds`, `durationMin`/`durationMax`, `workoutsPerWeekMin`/`workoutsPerWeekMax`, `includeFreeStock` (default `true`), `includePremiumStock` (default `false`), `includeTeamContent` (default `false`), `includeExerciseCollections`, `excludeExerciseCollections`.
- Scheduling/assignment: `repeatUntil`, `nextRunAt`, `assignToClientUserIds`.
- Prompt guidance: `promptGuidanceMode` (`none` | `custom` | `ai_tuned` | `ai_driven`, default `none`), `customPromptGuidance`.

**Response:** `id`, `organizationId`, `ownerId`, `name`, `status` (`active` | `draft`), `scope`, `contentType`, the configuration echo, `nextRunAt`, `lastRunAt`, `createdAt`, `updatedAt`. List/events use `limit` (1-50, default 20) + `offset`. Event `eventType` is one of `success`, `failed`, `skipped_limit`, `skipped_premium_lost`. Content generation runs count toward the **10x** AI weighting.

### AI Chat (conversational coach)
Conversational AI coach that can answer questions and propose actions (generate/adapt a workout or program, log nutrition) for an end user. `organizationId` is supplied **in the body/query**, not in the path. **Required on every request:** `organizationId` plus `userEmail` **or** `userExternalUUID`. Start/send count as **10x**; list conversations is **1x**.

**Entitlement gate (2026-07-06):** org API keys require the team owner's plan to include `features.aiCoachChat` (Platform/Ultra); standard Hyperhuman apps (Club/members web) pass if the end user's own club entitlement includes `features.ai_chat` (from their active end-user subscription, defaulting to `club-free`) **or** the organization's team plan includes `aiCoachChat`. Otherwise `403 Forbidden` with an upgrade message.

```bash
# Start a conversation (returns the conversation + the assistant's first reply)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/chat/conversations" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"organizationId":"'$ORG'","initialMessage":"Build me a 20-minute beginner workout","userEmail":"user@example.com"}'

# List conversations for a user (PAGE-based pagination: page + limit)
curl -s "https://content.api.hyperhuman.cc/v1/chat/conversations?organizationId=$ORG&userEmail=user@example.com&page=1&limit=20" \
  -H "X-Api-Key: $API_KEY"

# Read / send messages in a conversation (userEmail or userExternalUUID is REQUIRED on every call)
curl -s "https://content.api.hyperhuman.cc/v1/chat/conversations/$CONV_ID/messages?organizationId=$ORG&userEmail=user@example.com&page=1&limit=50" -H "X-Api-Key: $API_KEY"
curl -s -X POST "https://content.api.hyperhuman.cc/v1/chat/conversations/$CONV_ID/messages" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"organizationId":"'$ORG'","content":"Make it a bit harder","userEmail":"user@example.com"}'

# Confirm a pending AI action (e.g. save a proposed workout); archive a conversation
curl -s -X POST "https://content.api.hyperhuman.cc/v1/chat/conversations/$CONV_ID/actions/confirm" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"organizationId":"'$ORG'","pendingActionId":"'$PENDING_ID'","userEmail":"user@example.com"}'
curl -s -o /dev/null -w "%{http_code}" -X DELETE "https://content.api.hyperhuman.cc/v1/chat/conversations/$CONV_ID?organizationId=$ORG&userEmail=user@example.com" -H "X-Api-Key: $API_KEY"
```

**End-user binding (required):** **every** chat request must identify the end user with either `userEmail` or `userExternalUUID` (in the JSON body for POST, or as a query param for GET/DELETE). Omitting both returns **`400 BadRequest`** — `"Either userEmail or userExternalUUID must be provided to identify the end user"`. Resolution rules match `{endUserId}` elsewhere. **Pagination is `page`-based here** (`page` >= 1, `limit` up to 100) — unlike the offset/cursor pagination used by the rest of the Content API.

**Responses:** start → `{ conversation, response }`; send → `{ userMessage, assistantMessage, conversation }`; confirm → `{ success, message?, error?, instructionForReply?, adaptedWorkout?, generatedWorkout?, adaptedProgram?, generatedProgram?, nutritionOverview? }`. A message has `id`, `role`, `content`, `createdAt`, `metadata`, `status`; a conversation has `id`, `title`, `messageCount`, `lastMessageAt`, `createdAt`.

### Organization exercise library (Smart Library)
```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/video-assets?limit=20&offset=0" -H "X-Api-Key: $API_KEY"

# Optional: locale localizes name, muscleGroups[].name, equipment[].name, audioInstructions[]
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/video-assets?locale=en-US&limit=20&offset=0" \
  -H "X-Api-Key: $API_KEY"

# Filter: single-exercise rows for a given equipment + muscle group, beginner-only, sorted alphabetically
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/video-assets?kinds=single-exercise&equipmentIds=60d21b4667d0d8992e610c90&muscleGroupIds=60d21b4667d0d8992e610c85&skillLevels=beginner&sort=%2Bname" \
  -H "X-Api-Key: $API_KEY"

# Search by name (case-insensitive substring), locale-aware
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/video-assets?q=jumping&locale=en-US" \
  -H "X-Api-Key: $API_KEY"
```

#### Stock exercise catalog (Ultra annual or custom / Enterprise)

`GET /v1/orgs/{orgId}/stock-exercises` returns the **browsable stock catalog** - published, public, `single-exercise` clips (free + premium). Omitting `locale` uses **`en-US`**. The list does not return every locale's instruction audio. Pass another BCP-47 `locale` for that language. Keep `limit` at or below 50 and walk pages with `links.next`. Requires `contentApi` and positive API caps, plus either an **active Ultra (annual)** subscription (Stripe status `active`, yearly interval) or a custom/Enterprise plan with `stockVideoCatalogApi`. Otherwise **403**. Ultra monthly, Platform (including trial), and Content are not included. A Content plan with no API access is **401** on every route (the catalog route is never reached). Admitted calls count as **10x**. A plan-gated **403** does **not** consume units. This is a browse/playback surface, **not** AI-generation fuel.

```bash
# Browse free + premium stock single-exercises (default availability=all-stock)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/stock-exercises?limit=20&offset=0&locale=en-US" \
  -H "X-Api-Key: $API_KEY"

# Filter by muscle group + equipment, premium only, sorted alphabetically
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/stock-exercises?muscleGroupIds=60d21b4667d0d8992e610c85&equipmentIds=60d21b4667d0d8992e610c90&availability=premium-stock&sort=%2Bname" \
  -H "X-Api-Key: $API_KEY"

# Search by name (case-insensitive substring), locale-aware
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/stock-exercises?q=jumping&locale=en-US" \
  -H "X-Api-Key: $API_KEY"
```

**Params:** `offset`, `limit` (default **10**, max **50**), optional `locale`. **Filters** (AND-combined; a multi-value filter matches **any** listed value): `q` (case-insensitive substring; sort still applies), `equipmentIds`, `muscleGroupIds`, `skillLevels`, `executionSides`, `coach`, `collectionNames`, `availability` (`free-stock`/`premium-stock`/`all-stock`, default `all-stock`). There is **no `kinds` filter** - the catalog is hard-scoped to `single-exercise`. **Sort** `+`/`-` prefix on `date` (alias of `createdAt`) / `name` / `kind`; default `-date` (sort is case-sensitive, matching `video-assets`). **Catalog scope:** only canonical **published, public, `single-exercise`** stock clips; workspace-private uploads, team copies of stock, and pay-as-you-go provider content are never listed. **Response** reuses the `video-assets` item shape but **omits `trainer`, `bundle`, and `organization`** (stock is platform content); each `single-exercise` row carries `audioInstructions[]` for one locale (omitted `locale` is `en-US`). See [Organization exercise library](#organization-exercise-library-smart-library) for the workspace-upload counterpart.

### Exercise Groups (Micro Workouts)
```bash
# List micro workouts (paginated)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/groups?limit=10&offset=0" -H "X-Api-Key: $API_KEY"

# List with visibility filter (public, private, all)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/groups?visibility=public&limit=20&offset=0" -H "X-Api-Key: $API_KEY"

# Get micro workout detail (execution config: circuit/sets, rounds, rest)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/groups/$GID" -H "X-Api-Key: $API_KEY"

# Get exercises in micro workout
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/groups/$GID/exercises" -H "X-Api-Key: $API_KEY"
```

**List query parameters:**
- `visibility` (string, default: public) - Filter: `public`, `private`, or `all` (all requires API key for same org)
- `offset` (number, default: 0) - Pagination offset
- `limit` (number, default: 10, max: 50) - Items per page

**Note:** Set visibility to public in the Teams web app (team.hyperhuman.cc) for collections to appear in Content API list.

### AI User Insights
```bash
# $ENDUSER is the endUserId from the find call above (24-character hex). An externalUUID or an email also
# resolves, but an email in the path ends up in access logs.
# Get daily digest
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/insights/digest" \
  -H "X-Api-Key: $API_KEY"

# Get digest for specific date (date is an ISO 8601 date-time, not a bare YYYY-MM-DD)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/insights/digest?date=2026-01-10T00:00:00.000Z" \
  -H "X-Api-Key: $API_KEY"

# Force refresh (bypass server digest cache)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/insights/digest?forceRefresh=true" \
  -H "X-Api-Key: $API_KEY"

# Get pillar detail (training, recovery, health_pulse, movement, nutrition, body)
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/insights/pillars/training" \
  -H "X-Api-Key: $API_KEY"
# The movement pillar reflects logged wearable activities (runs/rides/swims)
# when available; otherwise it uses the wearable activity score. Look for activity_logged /
# activity_volume signals in highlights/insights.
```

### Health Data Push (batch write)
```bash
# Push health data entries (max 50 per request; counts as 1x regardless of batch size)
# dataTypes: activity | sleep | steps | body_metrics | heart_rate | hydration | oxygenation | scores
# activityType is free-form (common: Running, Cycling, Walking, Swimming, Hiking, Yoga,
# Strength Training, HIIT, Rowing). Swim fields only for swimming sessions.
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/insights/health-data" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "entries": [
      { "dataType": "activity", "externalId": "run-8842", "source": "AcmeHealthApp",
        "activityType": "Running", "startAt": "2026-08-05T07:30:00.000Z",
        "endAt": "2026-08-05T08:15:00.000Z", "durationSeconds": 2700,
        "distanceMeters": 8200, "steps": 7900, "activeCaloriesKcal": 512,
        "hrAvg": 148, "hrMax": 176, "paceAvgMinPerKm": 5.5 },
      { "dataType": "activity", "externalId": "swim-2201", "source": "AcmeHealthApp",
        "activityType": "Swimming", "startAt": "2026-08-05T18:30:00.000Z",
        "durationSeconds": 2700, "activeCaloriesKcal": 380, "hrAvg": 132,
        "swimDistanceMeters": 1500, "swimLaps": 30, "poolLengthMeters": 25 },
      { "dataType": "sleep", "recordedAt": "2026-08-05T08:00:00.000Z", "source": "AcmeHealthApp",
        "sleepScore": 82, "sleepEfficiency": 91, "hrvRmssd": 46, "hrResting": 52 },
      { "dataType": "steps", "recordedAt": "2026-08-05T21:00:00.000Z", "steps": 10412 },
      { "dataType": "body_metrics", "recordedAt": "2026-08-05T07:00:00.000Z",
        "weightKg": 78.4, "heightCm": 181 },
      { "dataType": "heart_rate", "recordedAt": "2026-08-05T21:00:00.000Z",
        "hrvRmssd": 44, "hrAvg": 64, "hrResting": 55, "hrMax": 132, "hrMin": 48 },
      { "dataType": "hydration", "recordedAt": "2026-08-05T21:00:00.000Z", "waterIntakeMl": 2100 },
      { "dataType": "oxygenation", "recordedAt": "2026-08-05T21:00:00.000Z", "spo2AvgPct": 97 },
      { "dataType": "scores", "recordedAt": "2026-08-05T22:00:00.000Z",
        "overallHealthScore": 79, "physicalScore": 74, "sleepScore": 85,
        "bodyScore": 81, "nutritionScore": 68 }
    ]
  }'
# Always returns 200 with per-entry results - check them for partial failures:
# { data: { results: [{ index, dataType, externalId, status: created|updated|rejected, reason? }],
#   summary: { total, succeeded, failed } } }
# Idempotency: re-POST the same (dataType, externalId) to update instead of duplicate.
# activity requires externalId; day-scoped types default to "day-YYYY-MM-DD" from recordedAt.
# Backfill window: max 90 days in the past, never future-dated.
# Swagger Try-it-out has named examples: full-day-batch, activities-mixed, activity-strength,
# day-vitals, body-and-scores, idempotent-correction.

# Delete a pushed entry (204; recomputes the day from remaining entries)
curl -s -X DELETE "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/insights/health-data/activity/run-8842" \
  -H "X-Api-Key: $API_KEY"
# For entries posted without externalId use the day key, e.g. .../health-data/sleep/day-2026-08-05
# Wearable-synced activities cannot be deleted through this endpoint (400).
```

### Direct Nutrition Logging
```bash
# Log macros for a day (same totals as photo and chat logging)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/nutrition/log" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{ "date": "2026-08-05", "caloriesKcal": 450, "proteinG": 25, "carbsG": 50, "fatG": 15,
        "externalId": "meal-5521", "source": "AcmeApp" }'
# Returns: { data: { logged, dayTotals, nutritionScore, externalId } }
# With externalId: re-POST replaces the previous contribution (idempotent, safe to retry).
# Without externalId: every call adds to the day totals (same as photo and chat logging).

# Delete a logged entry by externalId (204; subtracts from day totals, recomputes score)
curl -s -X DELETE "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/nutrition/log/meal-5521" \
  -H "X-Api-Key: $API_KEY"
```

### Nutrition Photo Logging (two-step)
```bash
# Step 1 - Analyze meal photo (counts as 10x API calls; JPEG/PNG/WebP, max 10 MB)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/nutrition/photo-log" \
  -H "X-Api-Key: $API_KEY" \
  -F "photo=@meal.jpg" \
  -F "date=2026-06-12" \
  -F "hint=half portion, no dressing"
# Returns: { data: { pendingLogId, expiresAt, estimate: { isFood, confidence, mealDescription,
#   items: [{ name, estimatedPortion, caloriesKcal, proteinG, carbsG, fatG }], totals } } }
# 400 when the photo does not contain food; 502 when vision analysis is unavailable

# Step 2 - Confirm (1x; optional corrected totals; single-use, expires after 1 hour)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/nutrition/photo-log/$PENDING_LOG_ID/confirm" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{ "totals": { "caloriesKcal": 600 } }'
# Returns: { data: { logged, dayTotals, nutritionScore, mealDescription, items } }
# 400 when expired; 404 when not found or already confirmed
```

### AI Insights Response Structure
```json
{
  "userId": "507f1f77bcf86cd799439011",
  "organizationId": "507f1f77bcf86cd799439011",
  "date": "2026-01-10T00:00:00.000Z",
  "pillars": [
    {
      "type": "health_pulse",
      "status": "green",
      "headline": "Score 82, up 5 vs your prior week. Readiness: push.",
      "action": "Maintain your sleep routine",
      "confidence": 0.85,
      "score": 82,
      "delta": 5,
      "driver": "sleep",
      "readinessBand": "push"
    },
    {
      "type": "training",
      "status": "green",
      "headline": "4 workouts this week with form at 78% and improving.",
      "action": "Next: Full Body Strength (high intensity)",
      "confidence": 0.8,
      "weeklyCompletions": 4,
      "formScore": 78,
      "formTrend": "improving",
      "why": ["4 of 4 planned sessions done", "Form up 6 points this week"],
      "insights": [
        { "id": "pace-week-complete", "kind": "pace", "pillar": "training", "priority": 5, "label": "Weekly target hit: 4/4.", "celebration": true, "isNewToday": true, "source": "deterministic", "copyKey": "pace.complete" }
      ]
    },
    {
      "type": "recovery",
      "status": "green",
      "headline": "Sleep score 91. 90-day best.",
      "action": "Keep tonight's bedtime.",
      "confidence": 0.8,
      "score": 91,
      "trend7d": [78, 81, null, 84, 80, 86, 91]
    }
  ],
  "todaysFocus": "Complete: Full Body Strength (high intensity)",
  "highlights": [
    { "id": "personal-best-sleepScore", "kind": "personal_best", "pillar": "recovery", "priority": 1, "label": "Sleep score 91. 90-day best.", "detail": "Previous best 88", "celebration": true, "isNewToday": true, "source": "deterministic", "copyKey": "personal_best.metric" },
    { "id": "pace-week-complete", "kind": "pace", "pillar": "training", "priority": 5, "label": "Weekly target hit: 4/4.", "celebration": true, "isNewToday": true, "source": "deterministic", "copyKey": "pace.complete" }
  ],
  "dataCompleteness": 0.75,
  "dataProfile": "wearable",
  "variant": "daily",
  "readiness": { "band": "push", "recommendedIntensity": "high", "message": "Recovered and ready.", "contributors": ["Sleep score 91", "HRV above baseline"] },
  "week": { "completed": 4, "planned": 4, "status": "ahead", "daysRemaining": 3, "weeklyStreakWeeks": 3 },
  "shareText": "Sleep score 91. 90-day best.",
  "locale": "en-US",
  "timeZone": "Europe/Bucharest",
  "generatedAt": "2026-01-10T08:30:00.000Z"
}
```
Treat `label` as the display string and `copyKey` as a stable template id; do not parse `label` or `id`.
| Field | Description |
|-------|-------------|
| pillars | 3-5 insight pillars (`health_pulse` always first; `body` only when real body data exists) |
| status | `green` (75-100), `yellow` (55-74), `red` (0-54) |
| delta | Change vs prior-7-day baseline (signed; omitted when today's data or baseline is missing) |
| deltaDirection | `up`, `down`, or `stable` (derived from delta) |
| todaysFocus | Single most important action for today |
| highlights | Up to 8 detected signals (priority-sorted): personal bests, strength PRs, milestones, streaks, pace, load, recovery debt, sleep / resting-HR / HRV trends, steps, weight trend, logged-activity celebration + weekly volume, patterns, CTAs, and a same-day member skip (`slot_skipped`, not a push); `source` is `deterministic` or `ai`; optional `copyKey` (stable template id). Kinds are additive — render unknown kinds generically (label + optional detail) |
| insights (per pillar) | Up to 3 signals homed on that pillar |
| dataCompleteness | 0-1 score (higher = more data sources available). Weighting depends on `dataProfile` |
| dataProfile | Optional. `wearable` or `workout_only` — a `workout_only` user is not "missing" a device; phrase limited-data hints accordingly |
| variant | Optional. `daily`, `week_ahead` (user-local Monday) or `week_review` (user-local Sunday) |
| readiness | Optional. `{ band, recommendedIntensity, message, contributors? }` — digest-level copy of the health_pulse readiness (`push` / `maintain` / `recover`), up to 3 contributor strings |
| week | Optional. `{ completed, planned, status, daysRemaining, weeklyStreakWeeks }` for the current user-local Monday-based week; `status` is `ahead` / `on_track` / `behind` / `no_target` |
| shareText | Optional. One shareable line (top celebration, or the Sunday review); absent when nothing is worth sharing |
| locale / timeZone | Optional. Locale the copy was written in and the IANA zone used for day boundaries (end-user profile time zone, else UTC) |
| trend7d (per pillar) | Optional. 7 user-local days of the pillar's primary metric, oldest first, `null` for missing days (sparkline) |
| why (per pillar) | Optional. Up to 3 short reasons behind the headline / action, now present on every pillar when data allows |
| readinessBand | Training capacity: `push`, `maintain`, or `recover` |
| riskLevel | Fatigue risk: `low`, `moderate`, or `high` (training pillar) |
| riskTriggers | Array of up to 3 contributing factors (e.g., "Sleep declining", "High RPE") |
| formCue | Primary technique focus for today |
| formSuggestion | Adjustment if form is declining |
| pacingStatus | Weekly pacing vs planned days (calendar week): `on_track`, `behind`, or `ahead`. The enum may be `behind`. Member-facing `pacingMessage` and pace signal labels do not say "Behind" (2026-10-22) |
| alternativeActions | Safe workout alternatives when risk detected |
| why | Array of up to 3 reasons for the recommendation |

---

## 4) Recommendations vs Generation (Choose Your Strategy)

| Scenario | Use Recommendations | Use Generation |
|----------|-------------------|----------------|
| **Speed** | Sub-second | Typically a few seconds — keep the HTTP client open ≥ **120s** |
| **Content** | Pre-built library (persisted ids) | Workout = ephemeral JSON (no id). Plan = top-level `id` **only** with `endUserProfileDetails.endUserId` |
| **Best for** | Quiz → curated team workouts/plans | On-demand custom content when the library is not enough |
| **Library Required** | Yes | No (stock / team exercise sources for workouts) |
| **Deterministic branches** | Your app picks among recommend results / library | Not a rules engine — AI-driven from documented fields only. Optional Programming DNA is opt-in (`methodologyProfileId`) on this API |
| **Programming DNA / Inspector** | Not applied | Optional `methodologyProfileId`. Response may include Generation Inspector (`methodology` / `inspection`). Store the snapshot — no GET-by-generation-id. Club / member web follow the published team default without that field |
| **Playback / retrieve** | `/playlist` + `/music` on library ids | Workout: JSON receiver / custom player. Plan: `GET /plans/{id}` only when `id` was returned |

### Quick Example - Recommendations
```bash
curl -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/recommend" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 28, "fitnessLevels": ["beginner"], "gender": "female"}
  }'
```

**Returns:** Bare `{ workouts, reasoning }` — up to 12 library workouts; each item has a `recommendation` **text** reason (not a numeric score). Unknown body keys (e.g. top-level `limit`, `durationOptions`) → `400 ValidationError`.

### Similar vs Recommend

Use **similar** when the user is already on one workout or program and you want others like **that item**. Use **recommend** when you have a member profile and want the library ranked for **that person**.

| | Similar | Recommend |
|---|---|---|
| Routes | `GET /v1/workouts/{id}/similar`, `GET /v1/plans/{id}/similar` | `POST /v1/orgs/{orgId}/workouts/recommend`, `POST .../plans/recommend` |
| Input | The open item. Query: `limit` (default **5**, max **10**), `visibility` (default **`public`**), `locale` | `endUserProfileDetails` and the documented optional filters |
| Match | Categories or goals, duration, difficulty, muscle focus. No profile. No AI | Profile against the published public library |
| Body | `{ data, links }` list rows plus up to two `matchLabels` (`Same style`, `Same muscles`, `Same length`, `Same level` on workouts; `Same goal`, `Same muscles`, `Same schedule`, `Same level` on programs). No score. `links.next` omitted. `links.total` is how many came back | Bare `{ workouts\|plans, reasoning }` plus a per-item `recommendation` sentence. Cap **12** |
| Pool | Same organization as the source. Workouts: published. Programs: trainer-owned `public`/`private` only (no `end-user-ai-generated`, no `originalProgramId`). Ranked from the **120** newest candidates. Program muscle focus uses the first **8** workouts | Published **public** only |
| Cost | **1x** | **10x** |

A result is returned only when it clears an internal floor and shares a category or goal, or has real muscle overlap. Same length and same level by themselves are not enough. A returned row always includes at least one `matchLabel`. Tags are not used. Unknown id, or a workout that is not `created` or `published`, is **404**. No organization on the source is **200** with `data: []`. A signed-in member who is not in that organization cannot read `private` (**403**); `all` falls back to `public`. An API key with no member token keeps the visibility you send, so omit it (or send `public`) on a public page.

```bash
curl -s "https://content.api.hyperhuman.cc/v1/workouts/$WORKOUT_ID/similar?limit=5&visibility=public" \
  -H "X-Api-Key: $API_KEY"
```

---

## Recipes: library rotation & ordered programs

**Terminology:** Content API paths say **plan**. Product / Teams UI says **Program**. Same entity (`WorkoutCollection`). Visibility values are **`public`** | **`private`** (Teams UI labels match the API: Public / Private). Workouts have **categories** (AI pools / quality) and optional **tags** (library filter/search only). Categories filter the pool; **Programs own sequence**. Tags do not filter recommend/generate.

### Populate the workspace catalog (pre-made stock)

Plan marketing includes thousands of pre-made workouts and programs. Those templates live in the Teams stock browser. The Content API lists **clones in this workspace**, not the global template catalog.

1. In [team.hyperhuman.cc](https://team.hyperhuman.cc) open **Workouts** or **Programs** → **Add from stock** (Platform / Ultra; Free can acquire as a teaser).
2. Clones get **new** workspace ids. Do not reuse invented hex strings or generate `instances[].id` values as workout ids.
3. `GET /v1/orgs/{orgId}/workouts` or `GET /v1/orgs/{orgId}/plans` — **200** with `data` + `links.total`. Zero items means the workspace is empty, not that stock is missing from the API.
4. Play with `GET /v1/workouts/{id}` + `/playlist` using an id from that list. AI generate stays ephemeral (JSON-receiver / `GET /v1/video-asset/{exerciseId}`).
5. There is **no** Content API to list or clone stock workout/program templates. `GET /v1/orgs/{orgId}/stock-exercises` is stock **clips** (Ultra yearly or custom), not workouts or programs.

### Rotate your workout library (retire without breaking IDs)

1. Keep old workouts in the library — **do not delete** if users already store their ids.
2. Set retired workouts to **`private`** (Teams: Private). They drop out of recommend / generate pools (public-only).
3. Keep active workouts **`public`**. Optionally tag them with a category (e.g. your “active rotation”) and pass `categoryIds` on recommend / generate.
4. Existing users keep playing via `GET /v1/workouts/{id}` — visibility does not block get-by-id for published content.

```bash
# Recommend only from an active category pool
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/recommend" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 30, "fitnessLevels": ["intermediate"]},
    "categoryIds": ["615aa3f0dbe7140012c59e94"]
  }'
```

### Deliver a fixed weekly sequence

**Order lives in a Program (plan), not in categories or workout tags.**

1. Build an org Program in Teams (drag workouts into week order) **or** generate with AI then reorder.
2. Integrators: `POST .../plans/recommend` to match a user to a library Program, then `GET /v1/plans/{planId}/workouts` for the ordered sequence.
3. For AI assembly from an active pool only: `POST .../plans/generate` with `categoryIds` + `workoutCollectionSources` — AI chooses order inside that pool; you control the pool via public/private + categories. Need **≥3** matching public workouts or the API returns **`400`**.

```bash
# AI program from a category-filtered public pool
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans/generate" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "goalIds": ["63c92926f920a9005c4e619c"],
    "workoutCollectionSources": ["team"],
    "categoryIds": ["615aa3f0dbe7140012c59e94"],
    "durationOptions": {"minWeeks": 4, "maxWeeks": 4},
    "workoutsPerWeek": 3,
    "difficulty": "intermediate"
  }'

# Fixed sequence for a known program id
curl -s "https://content.api.hyperhuman.cc/v1/plans/$PLAN_ID/workouts" \
  -H "X-Api-Key: $API_KEY"
```

---

## 5) User Management

### User Lifecycle Overview
1. **Invite** → Add users to organization (batch up to 100)
2. **Verify** → Check if user exists before inviting
3. **List** → View all organization users (paginated)
4. **Suspend** → Temporarily disable access
5. **Reactivate** → Restore suspended access
6. **Remove** → Permanent deletion

### User Status Types
- **Active** - User can access content
- **Suspended** - Temporarily disabled
- **Invited** - Pending first login

---

## 9) Organization Management

### Organization Metadata
Get your organization's branding, settings, and configuration:

```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/metadata" -H "X-Api-Key: $API_KEY"
```

**Returns:** Logo/watermark URLs, brand colors, description, join settings, module flags (`has*ModuleActive`), branded-app AI visibility flags (`aiChatEnabled`, `aiGenerationEnabled`, `aiAdaptationEnabled`, `aiRecommendationEnabled`, `insightsEnabled`), the team-level `insightsMode` (`coach` / `physio` / `weight_management` / `wellness`; a member's own insights mode, chosen in the member app, overrides it for that member) and `painCheckInEnabled` (post-workout check-ins should ask for a 0-10 `painScore`; true for physio / rehab teams).

> **`ai*Enabled` / `insightsEnabled` are not Content API gates.** They control branded-app UI. A flag may be `false` while the matching Content API route still succeeds for a capable API key (API uses separate capability checks on recommend/generate/adapt/chat/insights).

### Organization Join Options
Check how users can join your organization:

```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/join-options" -H "X-Api-Key: $API_KEY"
```

**Returns:** Join settings (OPEN, INVITE_ONLY, CLOSED) and configuration.

### Organization API Statistics
Monitor your API usage and rate limits:

```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/stats" -H "X-Api-Key: $API_KEY"
```

**Returns:** Current-hour and current-day **admitted weighted quota units**, effective rate limits, and
timestamps. These are not raw request counts: elevated operations consume 10 units, and requests denied
before quota admission do not increase the counters. Only `X-Api-Key` traffic is metered against this
org's client; JWT/member calls to the same `/v1/orgs/{orgId}/…` paths do not increment these counters.

---

## 6) Localization (18 Languages)

### Supported Locales:
| Code | Language | Region | Status |
|------|----------|--------|--------|
| `en-US` | English | United States | Default |
| `en-GB` | English | United Kingdom | Full |
| `en-AU` | English | Australia | Full |
| `fr-FR` | French | France | Full |
| `de-DE` | German | Germany | Full |
| `es-ES` | Spanish | Spain | Full |
| `it-IT` | Italian | Italy | Full |
| `pt-PT` | Portuguese | Portugal | Full |
| `he-IL` | Hebrew | Israel | Full |
| `ro-RO` | Romanian | Romania | Full |
| `cs-CZ` | Czech | Czech Republic | Full |
| `fi-FI` | Finnish | Finland | Full |
| `lt-LT` | Lithuanian | Lithuania | Full |
| `nl-NL` | Dutch | Netherlands | Full |
| `pl-PL` | Polish | Poland | Full |
| `ar-XA` | Arabic | Modern Standard Arabic | Full |
| `nb-NO` | Norwegian (Bokmål) | Norway | Full |
| `sv-SE` | Swedish | Sweden | Full |

### What Gets Localized:
✅ Exercise names  
✅ Exercise descriptions  
✅ Equipment names  
✅ Muscle group names  
✅ Audio instructions (voice-over)  
✅ Workout/plan titles & descriptions  
✅ Category names  
✅ Goal names (for plans)  
✅ AI-generated workout names (`POST /workouts/generate` with `locale`) — produced by the AI in the target locale and `en-US`, returned via `nameTranslations`; exercise/muscle-group/equipment names returned via additive resolved fields (`nameLocalized`, `muscleGroupsLocalized`, `equipmentLocalized`)
✅ AI-generated program names and descriptions (`POST /plans/generate` with `locale`) — produced by the AI in the target locale and `en-US`, returned via `nameTranslations` / `descriptionTranslations` (persisted on the saved plan **when** `endUserProfileDetails.endUserId` yields a top-level `id`); nested workout names/descriptions via `nameLocalized` / `descriptionLocalized`; program `difficultyLabel` localized read-side

### Fallback Behavior:
1. Requested locale (e.g., `fr-FR`)
2. Base language (e.g., `fr`)
3. English (`en-US`)
4. Original content language

Norwegian: browsers often send `no` / `no-NO`. Read-side locale chains alias those to `nb-NO` (Bokmål). **AI generate and other locale-validated routes reject `no`** (`400 Unsupported locale: no`) — send `nb-NO`. Nynorsk (`nn`) is not supported.

**Category, goal, and equipment names (2026-10-24).** Load ids from `GET /v1/workouts/metadata`, `GET /v1/plans/metadata`, and `GET /v1/workouts/equipment/metadata` in the environment you call. Sample ids in this sheet (HIIT `615aa3f0dbe7140012c59e94`, Lose weight `63c92926f920a9005c4e619c`, Bodyweight `67deb36a6a5e49797c122f56`) are examples from one environment. The same names have different ids elsewhere. Functional Training, Low-Impact Cardio, Improve Metabolic Health, Menopause Support, Reformer, Sled, Ski Erg, Weighted Vest, and Wall Ball have all 18 display names. Older category, goal, and equipment rows store 11 locales, so `lt-LT`, `pl-PL`, `ar-XA`, `nb-NO`, and `sv-SE` show the English name for those older rows. `en-GB` and `en-AU` use the `en-US` string. Functional Training, Low-Impact Cardio, Improve Metabolic Health, and Menopause Support have science profiles (2026-10-26). Menopause Support plan generate can return **400** when the pool is under 40% preferred categories. A plan generate whose goal still has no science profile returns **200** on the general-fitness path. A workout generate whose category still has no science profile keeps that category name and uses the Other rest and structure defaults. Create Similar does not apply those profiles. Choosing Free Weights or Gym Equipment does not yet return Reformer, Sled, Ski Erg, Weighted Vest, or Wall Ball clips, because no exercise references those equipment ids. The lists differ by environment.

### Comprehensive Localization Examples

#### Workout Details with Localization
```bash
# Get workout in French
curl -s "https://content.api.hyperhuman.cc/v1/workouts/64f1a2b3c4d5e6f7a8b9c0d1?locale=fr-FR" \
  -H "X-Api-Key: $API_KEY"

# Get workout in German with audio content filtered
curl -s "https://content.api.hyperhuman.cc/v1/workouts/64f1a2b3c4d5e6f7a8b9c0d1?locale=de-DE" \
  -H "X-Api-Key: $API_KEY"
```

#### Workout Playlist with Localized Audio
```bash
# Get French workout playlist with localized voice instructions
curl -s "https://content.api.hyperhuman.cc/v1/workouts/64f1a2b3c4d5e6f7a8b9c0d1/playlist?locale=fr-FR" \
  -H "X-Api-Key: $API_KEY"

# Get German workout with localized exercise names and audio
curl -s "https://content.api.hyperhuman.cc/v1/workouts/64f1a2b3c4d5e6f7a8b9c0d1/playlist?locale=de-DE" \
  -H "X-Api-Key: $API_KEY"
```

**Playlist audio metadata (`AudioLinks`):** Every playlist audio object (`intro.audio`, `exercise.intro.audio`, `exercise.main.instructionAudio[]`, `outro.audio`, class/multi `intro.audio`) keeps the existing **`url`** (required). Additive fields:

| Field | Notes |
|-------|--------|
| `type` | Role of the clip (`workoutIntro`, `workoutOutro`, `exerciseCue`, or `exerciseInstruction` on `main.instructionAudio[]` — always `exerciseInstruction` there) |
| `locale` | BCP-47 of the **selected** track after locale chain / fallback (same semantics as `audioInstructions[].locale` on video-assets); omitted only when the asset has no locale on file |
| `scriptText` | Verbatim narrator transcript for closed captions / offline TTS; **`""`** when no script is stored (legacy/stock). Treat empty string as “no caption text” |

**`exercise.main.instructionAudio[]`:** Built the same way as the export narration (per-instance instruction audio override, else the exercise defaults). Order matches playback (shuffle / time-based scheduling). For per-exercise catalog transcripts see `GET /v1/orgs/{orgId}/video-assets` **`audioInstructions[]`**.

#### Workout Playlist — Clip duration vs segment `duration`

For `{ "exercise": { ... } }` segments, **`duration` is the planned work window, not the MP4 file length.**

| Field | What it is |
|-------|------------|
| `exercise.duration` | Time-based: instance duration (seconds). Rep-based: `reps × estimatedRepDuration` (ms) / 1000 |
| `exercise.main.video.url` | Short looping demo clip (often ~3–40s). Recording extraction cuts a few clean reps for seamless loops |
| `exercise.main.video.loop` | `-1` when the asset `playsInLoop` is true (default). `0` when `playsInLoop` is false |

A ~27s file in a 60s time-based slot is **intended**. Keep looping the clip until the segment clock elapses. Do not end the segment when the file ends, and do not treat the shorter file as a processing bug.

`class` / `multiExercise` / `promo` / `educational` `duration` **does** match the source file (`videoDuration`). Intro videos also use `loop: -1` while the cue plays.

Same contract on **generate/adapt JSON**: `durationSeconds` (or `reps × estimatedRepDuration`) is the work window; `GET /v1/video-asset/{id}` is the looping demo (`playsInLoop` default true).

#### Workout Playlist — Poster vs video URL

Exercise-family segments (`exercise`, `class`, `multiExercise`, `promo`, `educational`) expose **two different URL kinds** on `intro.video` / `main.video`:

| Field | What it is | How to load |
|-------|------------|-------------|
| `video.url` | Time-limited **presigned progressive MP4** (~7 days, `X-Amz-*` query) | `<video src>` / AVPlayer / ExoPlayer with the string **verbatim** (no API key on the file, no query rewrite). Re-fetch the playlist before expiry. Not HLS. |
| `video.poster` / `video.thumbnail` | **Public unsigned S3 JPEG** (`Content-Type: image/jpeg`). Typical path `.../e/{id}/poster_a_720p.jpg` or `poster_a_1080p.jpg`. Same still on intro and main. | `<img src>` or `<video poster>`. **No API key.** Not signed. Not HTML. |

**Do:** use `exercise.main.video.poster` (or `class.main.video.poster`, etc.) from a **live** playlist response.

**Do not:**

- Use `video.url` as the thumbnail (that is the MP4).
- Copy OpenAPI / Swagger example hosts (`example.com/image/...` returns **HTML**; `images.hyperhuman.cc` does not resolve). Those are placeholders, not production assets.
- Bind a missing poster to `<img src="">` — browsers then request the **current page** (HTML).
- Expect posters on workout **intro** / **outro**: runtime `video` is `{ url }` only. Breaks use `image.url`, not `video.poster`.
- Confuse segment posters with `GET /v1/workouts/{id}` `preview.poster` (cover / still / clip — also usually a public JPEG, but a different field). Playlist segments always use the exercise still (`thumbnailUri`), not `coverImage`.

```bash
# Inspect posters on a persisted workout (replace $WID / $API_KEY)
curl -s "https://content.api.hyperhuman.cc/v1/workouts/$WID/playlist" \
  -H "X-Api-Key: $API_KEY" \
  | python3 -c 'import json,sys; d=json.load(sys.stdin)
for s in d["data"]:
  k=next(iter(s)); b=s[k]
  for path in ("intro","main"):
    v=(b.get(path) or {}).get("video") or (b.get("video") if path=="main" else None)
    if v: print(k, path, "poster=", v.get("poster"), "url_is_signed=", "X-Amz-" in (v.get("url") or ""))'
```

A healthy poster URL looks like `https://hhcontent.s3.eu-central-1.amazonaws.com/t/.../e/.../poster_a_720p.jpg` and returns `200` + `image/jpeg` with **no** query string.

#### Play signed media URLs verbatim

Playlist / video-asset / music / export `url` and `assetUri` values are AWS SigV4 **query-signed** S3 GET links to progressive MP4 or M4A. The query string *is* the credential. First-party players pass that string straight into `<video>` / `<audio>` / AVPlayer / ExoPlayer.

**Do:**

- Copy the `url` from the JSON response into the player **unchanged**.
- Use `<video src>` / `<audio src>` / native `AVPlayer` / `ExoPlayer` (or a plugin that forwards the raw string).
- Let the player send `Range` — byte-range GETs are allowed.
- Re-fetch `/playlist`, `/music`, `/video-asset/{id}`, or `export/.../stream_url` before the ~7-day expiry.
- Expect repeated reads of the same asset within about an hour to return the **same** URL string, so your HTTP cache can reuse the bytes. Treat that as incidental: key your own caches by workout / exercise **id**, never by URL.

**Do not:**

- Log, persist, or share signed URLs. The query string is a bearer credential for that object until it expires; store the resource id and re-fetch instead.
- Send `X-Api-Key` or `Authorization` when fetching the **media** URL. Those headers are for Content API routes only.
- Parse and rebuild the URL (`Uri.parse` → `toString()`, `queryParameters` maps, `encodeURI` / `encodeURIComponent` on the whole string). Re-encoding `%2F` in `X-Amz-Credential` or dropping `x-amz-*` params makes S3 return **403**.
- Assume HLS, a second host, extra playback headers, or a different URL variant for workout intro (`preview_1080p`) vs exercise clips (`video_a_720p`) vs music. Same contract.

If Safari or Chrome plays the URL and a native / Flutter / React Native player fails with “no permission” or a secure-connection error, compare the request URL to the JSON string. They must match. A rewritten signature is **403**, not a codec mismatch.

```javascript
// API call — key belongs here
const playlist = await fetch(`${base}/v1/workouts/${id}/playlist`, {
  headers: { 'X-Api-Key': apiKey }
}).then((r) => r.json());

const first = playlist.data[0];                 // single-key object, e.g. { intro: { video: { url } } }
const segment = first.intro || Object.values(first)[0];
videoEl.src = segment.video.url;                // keep this string; do not Uri-rebuild
```

#### Workout Playlist — Class Segments & Chapters

Class exercises (`kind: class` on the underlying video asset) appear in the playlist as a single-key segment: `{ "class": { ... } }`. When the class exercise has AI chapter analysis, `class.chapters` lists timestamped exercise blocks inside `main.video`. When analysis has not been run, **`chapters` is omitted** — play the class as one continuous segment.

> **Migration:** Workouts that include a class exercise previously returned `{ "multiExercise": { "kind": "multi-exercise", ... } }` for that slot. They now return `{ "class": { "kind": "class", ... } }`. Update segment parsers accordingly.

```bash
# Class workout playlist (chapters included when analysis has been run)
curl -s "https://content.api.hyperhuman.cc/v1/workouts/$WID/playlist?locale=en-US" \
  -H "X-Api-Key: $API_KEY"
```

**`class.chapters[]` fields** (each item):

| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Exercise name within the class session |
| `startTimeSec` | number | Start timestamp in seconds within `main.video` |
| `endTimeSec` | number | End timestamp in seconds within `main.video` |
| `equipment` | `{ id, name }[]` | Catalog ids + labels stored at analysis time (may be empty) |
| `muscleGroups` | `{ id, name }[]` | Catalog ids + labels stored at analysis time (may be empty) |

**Player integration:**
- Detect segment: `segment.class != null`
- Seek for chapter navigation: `videoElement.currentTime = chapter.startTimeSec` on `main.video`
- Segment-level `name`, `equipment`, and `muscleGroups` follow `?locale=`; **`chapters` text is not re-localized** (stored snapshot from analysis)
- Chapter metadata is **not** on `GET /v1/orgs/{orgId}/video-assets` — consume it from the workout playlist only

**Example fragment** (inside `data[]`):

```json
{
  "class": {
    "kind": "class",
    "id": "64f1a2b3c4d5e6f7a8b9c0d1",
    "exerciseId": "507f1f77bcf86cd799439011",
    "duration": 1200,
    "name": "Morning Strength Class",
    "equipment": [{ "id": "60812e08606fc90011eb4408", "name": "Kettlebell" }],
    "muscleGroups": [{ "id": "604b0177076fe2727354d743", "name": "Full Body" }],
    "intro": {
      "audio": { "url": "https://..." },
      "video": { "url": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/class-intro.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...", "poster": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/poster_a_720p.jpg", "thumbnail": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/poster_a_720p.jpg", "loop": -1 }
    },
    "skipIntro": false,
    "main": {
      "video": { "url": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/class-main.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...", "poster": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/poster_a_720p.jpg", "thumbnail": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/poster_a_720p.jpg" }
    },
    "chapters": [
      {
        "name": "Goblet Squat",
        "startTimeSec": 180.5,
        "endTimeSec": 420.0,
        "equipment": [{ "id": "60812e08606fc90011eb4408", "name": "Kettlebell" }],
        "muscleGroups": [{ "id": "604b0177076fe2727354d743", "name": "Quadriceps" }]
      }
    ]
  }
}
```

#### Plan Details with Localization
```bash
# Get training plan in Spanish
curl -s "https://content.api.hyperhuman.cc/v1/plans/507f1f77bcf86cd799439017?locale=es-ES" \
  -H "X-Api-Key: $API_KEY"

# Get plan workouts with localized content
curl -s "https://content.api.hyperhuman.cc/v1/plans/507f1f77bcf86cd799439017/workouts?locale=it-IT" \
  -H "X-Api-Key: $API_KEY"
```

#### Video/Audio Exports with Localization
```bash
# Export workout video with Italian voice narration
curl -s "https://content.api.hyperhuman.cc/v1/workouts/64f1a2b3c4d5e6f7a8b9c0d1/export/video/stream_url?locale=it-IT" \
  -H "X-Api-Key: $API_KEY"

# Export workout audio with Dutch instructions
curl -s "https://content.api.hyperhuman.cc/v1/workouts/64f1a2b3c4d5e6f7a8b9c0d1/export/audio/stream_url?locale=nl-NL" \
  -H "X-Api-Key: $API_KEY"
```

#### Metadata with Localization
```bash
# Get categories and difficulties in Portuguese
curl -s "https://content.api.hyperhuman.cc/v1/workouts/metadata?locale=pt-PT" \
  -H "X-Api-Key: $API_KEY"

# Get equipment metadata in Czech
curl -s "https://content.api.hyperhuman.cc/v1/workouts/equipment/metadata?locale=cs-CZ" \
  -H "X-Api-Key: $API_KEY"

# Get muscle groups in French
curl -s "https://content.api.hyperhuman.cc/v1/workouts/exercises/metadata?locale=fr-FR" \
  -H "X-Api-Key: $API_KEY"

# Get plan goals and difficulties in Finnish
curl -s "https://content.api.hyperhuman.cc/v1/plans/metadata?locale=fi-FI" \
  -H "X-Api-Key: $API_KEY"
```

#### Localized Content Generation
```bash
# Generate workout in French (minimal request)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/generate" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 28, "fitnessLevels": ["intermediate"]},
    "exerciseSources": ["premium_stock"],
    "durationOptions": {"minMinutes": 10, "maxMinutes": 15},
    "categoryId": "615aa3f0dbe7140012c59e94",
    "locale": "fr-FR"
  }'

# Generate plan in French (minimal — no top-level id without endUserId)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans/generate" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "goalIds": ["63c92926f920a9005c4e619c"],
    "workoutCollectionSources": ["template"],
    "durationOptions": {"minWeeks": 4, "maxWeeks": 4},
    "workoutsPerWeek": 2,
    "difficulty": "intermediate",
    "locale": "fr-FR"
  }'

# Generate plan with persistence (store returned id)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans/generate" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{
    "goalIds": ["63c92926f920a9005c4e619c"],
    "workoutCollectionSources": ["template"],
    "durationOptions": {"minWeeks": 4, "maxWeeks": 4},
    "endUserProfileDetails": {
      "age": 30,
      "fitnessLevels": ["beginner"],
      "endUserId": "64e3bca9d1b7e600141a9712"
    },
    "locale": "fr-FR"
  }'

# Invalid locale → 400
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/generate" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"categoryId":"615aa3f0dbe7140012c59e94","durationOptions":{"minMinutes":10,"maxMinutes":15},"exerciseSources":["premium_stock"],"locale":"xx-XX"}'
# → {"error":{"code":"BadRequest","message":"Unsupported locale: xx-XX"}}
```

#### Localized Recommendations
```bash
# Get workout recommendations with Hebrew content
# Body whitelist: only endUserProfileDetails + optional locale (no top-level durationOptions)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts/recommend" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 28, "fitnessLevels": ["beginner"]},
    "locale": "he-IL"
  }'

# Get plan recommendations with Romanian content
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans/recommend" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endUserProfileDetails": {"age": 28, "fitnessLevels": ["beginner"]},
    "goalIds": ["63c92926f920a9005c4e619c"],
    "locale": "ro-RO"
  }'
```

#### JavaScript Implementation with Locale Management
```javascript
class LocalizedApiClient {
  constructor(apiKey, defaultLocale = 'en-US') {
    this.apiKey = apiKey;
    this.defaultLocale = defaultLocale;
    this.supportedLocales = [
      'en-US', 'en-GB', 'en-AU', 'fr-FR', 'de-DE', 'es-ES',
      'it-IT', 'pt-PT', 'he-IL', 'ro-RO', 'cs-CZ', 'fi-FI',
      'lt-LT', 'nl-NL', 'pl-PL', 'ar-XA', 'nb-NO', 'sv-SE'
    ];
  }

  // Get user's preferred locale (from browser/device settings)
  getUserLocale() {
    if (typeof navigator !== 'undefined') {
      return navigator.language || this.defaultLocale;
    }
    return this.defaultLocale;
  }

  // Normalize locale to supported format
  normalizeLocale(locale) {
    if (this.supportedLocales.includes(locale)) {
      return locale;
    }
    // Norwegian macrolanguage (no / no-NO) -> Bokmal. Generate endpoints require nb-NO.
    if (locale === 'no' || locale === 'no-NO') {
      return 'nb-NO';
    }
    // Try base language (e.g., 'fr' from 'fr-CA')
    const baseLang = locale.split('-')[0];
    const mapped = { nb: 'nb-NO', sv: 'sv-SE', ar: 'ar-XA', no: 'nb-NO' };
    if (mapped[baseLang]) {
      return mapped[baseLang];
    }
    const baseLocale = `${baseLang}-${baseLang.toUpperCase()}`;
    if (this.supportedLocales.includes(baseLocale)) {
      return baseLocale;
    }
    return this.defaultLocale;
  }

  async getWorkout(id, locale) {
    const normalizedLocale = this.normalizeLocale(locale || this.getUserLocale());
    const response = await fetch(
      `https://content.api.hyperhuman.cc/v1/workouts/${id}?locale=${normalizedLocale}`,
      { headers: { 'X-Api-Key': this.apiKey } }
    );
    return response.json();
  }

  // Ephemeral JSON (no id). Free-text field is userRequirements, not userGuidance.
  // Optional options.methodologyProfileId (opt-in Programming DNA). Persist response.methodology / response.inspection (Generation Inspector) if you need an audit trail.
  async generateWorkout(profile, options, locale) {
    const normalizedLocale = this.normalizeLocale(locale || this.getUserLocale());
    const response = await fetch(
      `https://content.api.hyperhuman.cc/v1/orgs/myorg/workouts/generate`,
      {
        method: 'POST',
        headers: {
          'X-Api-Key': this.apiKey,
          'Content-Type': 'application/json'
        },
        signal: AbortSignal.timeout(120000),
        body: JSON.stringify({
          ...options,
          endUserProfileDetails: profile,
          locale: normalizedLocale
        })
      }
    );
    return response.json();
  }

  // Pass profile.endUserId to receive a top-level plan id (persistence).
  // Without it the response has no id — you cannot GET /v1/plans/{id}.
  // Optional options.methodologyProfileId. Persist response.methodology / response.inspection — later GET /plans/{id} does not replay them.
  async generatePlan(profile, options, locale) {
    const normalizedLocale = this.normalizeLocale(locale || this.getUserLocale());
    const response = await fetch(
      `https://content.api.hyperhuman.cc/v1/orgs/myorg/plans/generate`,
      {
        method: 'POST',
        headers: {
          'X-Api-Key': this.apiKey,
          'Content-Type': 'application/json'
        },
        signal: AbortSignal.timeout(120000),
        body: JSON.stringify({
          ...options,
          endUserProfileDetails: profile,
          locale: normalizedLocale
        })
      }
    );
    return response.json();
  }

  async getWorkoutPlaylist(workoutId, locale) {
    const normalizedLocale = this.normalizeLocale(locale || this.getUserLocale());
    const response = await fetch(
      `https://content.api.hyperhuman.cc/v1/workouts/${workoutId}/playlist?locale=${normalizedLocale}`,
      { headers: { 'X-Api-Key': this.apiKey } }
    );
    return response.json();
  }
}

// Usage
const api = new LocalizedApiClient('your-api-key');

// Automatically uses browser locale
const workout = await api.getWorkout('workout-id');

// Explicitly specify locale
const frenchWorkout = await api.getWorkout('workout-id', 'fr-FR');

// Generate workout in user's preferred language
const generated = await api.generateWorkout(
  { age: 28, fitnessLevels: ['beginner'] },
  { exerciseSources: ['premium_stock'], durationOptions: { minMinutes: 20, maxMinutes: 30 } }
);
```

---

## 7) Security & Rate Limiting

### API Key Security
- Tied to specific `organizationId`
- Cannot access other organizations' private content
- Can access public workouts/plans from any organization
- User management limited to own organization

### Common Security Errors:
**403 Forbidden:**
```json
{ "error": { "code": "Forbidden", "message": "API key is not authorized for this organization" } }
```

Plan-gated 403 (e.g. stock catalog on Platform/trial):
```json
{ "error": { "code": "Forbidden", "message": "Stock exercise catalog API requires Ultra (annual) or a custom plan with catalog access. Contact sales for a custom plan." } }
```

**401 Unauthorized:**
```json
{ "error": { "code": "Unauthorized", "message": "Unauthorized" } }
```

> The verified live envelope is `{ "error": { "code": "Unauthorized", "message": "..." } }` (see [Errors & Rate Limiting](#11-errors--rate-limiting)). A **locked or disabled** key returns `message: "API key is not authorized"`.

### Locked or Disabled API Keys
A key can be **locked** or have authorization **disabled** (e.g. revoked, suspended for billing, or unlocked by an admin). Such keys are rejected with **`401`** and `message: "API key is not authorized"` — this is permanent until an admin re-enables the key, so **do not retry**.

If a locked key keeps hammering the API, the server applies back-pressure: after repeated unauthorized attempts in a short window (currently ~10 per ~60s per key), it short-circuits to **`429 Too Many Requests`** with a **`Retry-After`** header (seconds) plus `X-RateLimit-Limit` / `X-RateLimit-Remaining: 0`, instead of continuing to return `401`.

**Client guidance:**
- Treat `401 "API key is not authorized"` as a stop condition — surface "API key disabled, contact your administrator", do **not** auto-retry.
- If you receive `429` after such `401`s, you are being throttled for retrying a disabled key. Honor `Retry-After` and stop the loop.

### Rate Limiting Headers
Every authenticated response includes the live cap for **this** API key. Do not hard-code a sample number:
```
X-RateLimit-Limit:     <your plan's current window cap>
X-RateLimit-Remaining: <weighted units left>
X-RateLimit-Reset:     <unix timestamp>
Retry-After:           <seconds to wait — present on 429; honor this first>
```

### Rate Limits
Caps are **plan-based** (entitlement + any scale pack). There is no global 1000/100/500 table.

**Trial:** while the team Stripe subscription is `trialing`, hourly and daily caps are **1/100** of the paid values (minimum 1). Example: Platform 2500/hour and 50000/day becomes **25/hour** and **500/day**. Read `X-RateLimit-Limit`.

**10x weight** (consumed when the request is admitted, including 400/403):
- Workout/plan generate, recommend, adapt
- User insights (digest, pillars)
- Chat **writes** (start conversation, send message) — listing conversations is **1x**
- Video generation
- Nutrition photo-log analysis (the confirm step counts as 1x)
- Full video export (`GET /workouts/{id}/export/video/stream_url`)
- Stock exercise catalog (`GET /v1/orgs/{orgId}/stock-exercises`) — Platform/trial/Ultra monthly keys get **403** and do **not** consume units

**1x writes:** the health-data batch push (`POST .../insights/health-data`, regardless of batch size — capped at 50 entries instead), its DELETE, and the direct nutrition log endpoints (`POST`/`DELETE .../nutrition/log[...]`) all count as **1x** per request.

**1x playback / hosted embeds:** `GET /workouts/{id}`, `/playlist`, `/music`, `GET /video-asset/{id}`, org metadata, and the session lifecycle are **1x**. The hosted Embedded Player uses those same endpoints with the key from the iframe URL or `HYPERHUMAN_EMBED_INIT`. There is no separate embed-play unit.

### Trial / first-integration checklist
A Platform trial key that “does not work” is usually one of these — not an outage:

1. Read `X-RateLimit-Limit` (often **25/hour**, **500/day** on Platform trial). Do not assume 1000/hour.
2. Do **not** call `GET .../stock-exercises` unless the plan is **Ultra annual** or **custom catalog**. Plan-gated **403 does not consume** quota. Content-only keys are **401** on all routes.
3. `GET .../groups` `limit` max is **50** (`limit=100` → **400**). End-user list max is **100**.
4. `GET /v1/chat/conversations` requires `organizationId` **and** `userEmail` or `userExternalUUID`.
5. On **429**, stop and honor `Retry-After`. Most failed 400/403 still consumed units (stock plan-gate 403 is the exception).
6. `POST .../workouts/generate`, `POST .../plans/generate`, recommend, and adapt succeed with **HTTP 200** (not 201). 201 is for resource creates (sessions, invite, chat start, autopilot / social-publish).
7. Generate free-text is `userRequirements`; adapt is `userGuidance`. The other name → **400** `property … should not exist`.
8. Keep the HTTP client open ≥ **120s** on generate/adapt. A 30s abort can still finish **200** server-side — do not immediately retry.
9. Do not invent catalog paths (`/library`, `/categories`, unscoped `GET /v1/workouts`). Use `GET /v1/orgs/{orgId}/workouts` and `GET /v1/workouts/metadata`.
10. Workout generate/adapt JSON has **no persisted id**. Do not `GET /v1/workouts/{id}/playlist` on that body or on locally minted hex ids. Empty org catalog is **200**, not 404.
11. Programming DNA on this API is opt-in: `GET .../methodologies` (up to 10 team profiles) then `methodologyProfileId` on generate only. Unknown / personal / inactive ids → **400**. Persist the Generation Inspector snapshot (`methodology` / `inspection`) if you need an audit trail. Do not invent a silent team default on organization API keys, adapt/chat methodology fields, or `GET /v1/generations/{id}`. Club / member web follow the published team default without that field.

### Comprehensive Error Handling & Retry Logic

#### Rate Limiting (429)
```javascript
async function handleRateLimit(response) {
  const retryAfter = response.headers.get('Retry-After');
  const resetTime = response.headers.get('X-RateLimit-Reset');
  const remaining = response.headers.get('X-RateLimit-Remaining');
  const limit = response.headers.get('X-RateLimit-Limit');

  const waitSeconds = retryAfter
    ? Number(retryAfter)
    : resetTime
      ? Number(resetTime) - Math.floor(Date.now() / 1000)
      : 0;

  if (waitSeconds > 0) {
    console.log(`Rate limited: ${remaining}/${limit} units remaining. Retry in ${waitSeconds}s`);
    await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
    return true; // Retry
  }
  return false; // Don't retry
}
```

#### Exponential Backoff Retry Function
```javascript
async function apiCallWithRetry(endpoint, options, maxRetries = 3) {
  let attempt = 0;

  while (attempt < maxRetries) {
    try {
      const response = await fetch(endpoint, options);

      // Handle rate limiting
      if (response.status === 429) {
        const shouldRetry = await handleRateLimit(response);
        if (shouldRetry && attempt < maxRetries - 1) {
          attempt++;
          continue;
        }
        throw new Error(`Rate limited after ${maxRetries} attempts`);
      }

      // Handle other errors
      if (!response.ok) {
        const errorData = await response.json().catch(() => ({}));

        // Don't retry client errors (4xx)
        if (response.status >= 400 && response.status < 500) {
          throw new Error(`Client error ${response.status}: ${errorData.message || response.statusText}`);
        }

        // Retry server errors (5xx) with exponential backoff
        if (response.status >= 500 && attempt < maxRetries - 1) {
          attempt++;
          const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 30000);
          console.log(`Server error ${response.status}, retrying in ${delay}ms...`);
          await new Promise(resolve => setTimeout(resolve, delay));
          continue;
        }

        throw new Error(`Server error ${response.status}: ${errorData.message || response.statusText}`);
      }

      return await response.json();

    } catch (error) {
      if (attempt === maxRetries - 1) {
        throw error;
      }
      attempt++;
    }
  }
}
```

#### Usage Example
```javascript
// Generate workout with automatic retry logic
try {
  const workout = await apiCallWithRetry(
    'https://content.api.hyperhuman.cc/v1/orgs/myorg/workouts/generate',
    {
      method: 'POST',
      headers: {
        'X-Api-Key': 'your-api-key',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        endUserProfileDetails: { age: 28, fitnessLevels: ['beginner'] },
        exerciseSources: ['premium_stock'],
        durationOptions: { minMinutes: 20, maxMinutes: 30 },
        categoryId: '615aa3f0dbe7140012c59e94'
      })
    },
    3 // max retries
  );

  console.log('Generated workout:', workout);
} catch (error) {
  console.error('Failed to generate workout:', error.message);
  // Handle error appropriately (show user message, fallback, etc.)
}
```

#### Error Type Handling
```javascript
function getErrorMessage(error, statusCode) {
  // Network errors
  if (error.name === 'TypeError' && error.message.includes('fetch')) {
    return 'Network connection failed. Please check your internet connection.';
  }

  // Rate limiting
  if (statusCode === 429) {
    return 'Too many requests. Please wait a moment before trying again.';
  }

  // Authentication errors
  if (statusCode === 401) {
    return 'Authentication failed. Please check your API key.';
  }

  if (statusCode === 403) {
    return 'Access denied. Your API key may not be authorized for this organization.';
  }

  // Not found errors
  if (statusCode === 404) {
    return 'The requested resource was not found. Please check your IDs.';
  }

  // Validation errors
  if (statusCode === 422) {
    return 'Invalid request data. Please check your parameters.';
  }

  // Server errors
  if (statusCode >= 500) {
    return 'Server error. Please try again in a few moments.';
  }

  // Default
  return 'An unexpected error occurred. Please try again.';
}
```

#### Circuit Breaker Pattern

`timeout` here is the **recovery wait** after the circuit opens — not the HTTP client timeout. Generate/adapt fetch still needs ≥ **120s**.
```javascript
class CircuitBreaker {
  constructor(failureThreshold = 3, timeout = 60000) {
    this.failureThreshold = failureThreshold;
    this.timeout = timeout;
    this.failureCount = 0;
    this.lastFailureTime = null;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
  }

  async execute(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }
}

// Usage
const breaker = new CircuitBreaker();

async function safeApiCall() {
  return breaker.execute(() =>
    apiCallWithRetry('https://content.api.hyperhuman.cc/v1/workouts/metadata', {
      headers: { 'X-Api-Key': 'your-key' }
    })
  );
}
```

---

## 8) Embeds (HTML)
**Pre‑built workout** (landscape card). For mobile/portrait prefer a `100vh`/`100dvh` full-viewport container (see API overview **Embedded Player**).
```html
<iframe
  src="https://team.hyperhuman.cc/embed/workout/{workoutId}?organizationId={orgId}&apiKey={apiKey}&locale=en-US"
  style="width:100%;aspect-ratio:16/9;border:0;border-radius:8px"
  allow="autoplay; fullscreen; accelerometer; gyroscope"
  referrerpolicy="strict-origin-when-cross-origin" allowfullscreen>
</iframe>
```
Optional query params: `segment`, `presentationStyle` (`full`/`minimal`/`essential`/`noOverlays`), `videoAudioMode` (`full`/`instructionsOnly`/`beepsOnly`/`none`), `mixBgMusic` (`0`/`1`), `muted` (`1` = hard mute all audio), `autoplay` (`1`), `brandLogoUrl` / other `brand*` overrides, `sessionMode` / `endUserId` / `externalUserId` (pre-built sessions). Use `videoAudioMode=instructionsOnly` or `mixBgMusic=0` to keep voice while dropping background music. PulseMix in-player music-off **pauses** BGM (iOS ignores `volume`).

**Keep the key out of the URL (optional):** drop `apiKey` and add `keyHandshake=1`, then deliver the key over `postMessage` exactly like the AI‑generated receiver below. The key stays **client‑visible** either way — `keyHandshake` only keeps it out of the URL / history / logs / `Referer`. There is **no publishable/scoped player key**. For a key that never reaches the browser, render server‑side and use the export `stream_url` endpoints or your own player (`X-Api-Key` stays on your backend).

**AI‑generated (push JSON)** — the iframe announces readiness; reply with the API key **and** workout JSON in a single `HYPERHUMAN_EMBED_INIT` envelope. The key is **not** in the iframe URL, and the post is pinned to the player origin (never `'*'`).
```html
<iframe id="player"
  src="https://team.hyperhuman.cc/embed/workout-json-receiver?organizationId={orgId}"
  referrerpolicy="strict-origin-when-cross-origin"></iframe>
<script>
  var HYPERHUMAN_PLAYER_ORIGIN = 'https://team.hyperhuman.cc';
  addEventListener('message', (e) => {
    if (e.origin !== HYPERHUMAN_PLAYER_ORIGIN) return;            // ignore other frames
    if (e.data === 'hyperhumanPlayerReadyForData') {
      document.getElementById('player').contentWindow.postMessage({
        type: 'HYPERHUMAN_EMBED_INIT',
        apiKey: '{apiKey}',       // your Content API key
        workout: workoutJSON      // payload from POST /v1/orgs/{orgId}/workouts/generate
      }, HYPERHUMAN_PLAYER_ORIGIN);
    }
  });
</script>
```
Back‑compat: the receiver still accepts a legacy `apiKey` query param and a raw (un‑enveloped) JSON post, but new integrations should use the envelope. Full param tables and the workout JSON payload shape live in the API overview (**Embedded Player**).

**JSON receiver media:** load as an **iframe** (not top-level). Ready signal ≈1s after load (`hyperhumanPlayerReadyForData`). Missing `organizationId` → inline error, no ready. Pass generate JSON as `workout`; the player waits for a non-empty `apiKey`, then calls `GET /v1/video-asset/{exerciseId}` (instruction clips capped at 3; some assets have none; **1x** each). Put the member's BCP-47 language on the iframe as `locale`. The player forwards it onto each video-asset GET. If that query is absent, the player and the API both use `en-US`. One GET returns one language of instruction and cue audio. Generate may include `videoUri` — embed still re-fetches by id. Invalid key → **401** on video-asset (console), not a blocking splash. **No PulseMix / sessions / feedback** — `sessionMode` on INIT is ignored for JSON. Use the pre-built embed for music, audio overrides, and session attribution.

**Session ownership (pre-built embed only):** `/embed/workout/{id}` accepts `sessionMode` (`anonymous` default · `attributed` · `host`) plus `endUserId` / `externalUserId` as query params and/or on `HYPERHUMAN_EMBED_INIT` (URL wins if both set). Optional `programPosition` (0-based `positionInPlan`) is **INIT only**. The player sends it on session start, end, and complete, and not on PATCH. An attributed play continues `GET .../sessions/recent` so `sessions/start` does not close that open session and credit a second slot. The continued clock does not PATCH below that session's stored `duration`. A finish during that lookup is sent once the session id exists. In-player feedback is shown after `sessions/complete` (feedback force-completes the row and does not take `programPosition`). Playback still starts at the first segment. An in-progress restart continues the open session; after a real complete, recent is 404. Use `attributed` + `endUserId` for a per-user session and in-player feedback (option **hex ids** from `GET /workouts/feedback/options`); `host` emits `HYPERHUMAN_SESSION_EVENT`. Both iframes accept `CAST_CONTROL` (`play`/`pause`/`next`/`prev`/`restart`/`done`/`done_exercise`) and post `CAST_STATE` (`exerciseName` / chapter fields). Optional: host → `HYPERHUMAN_HOST_IMMERSIVE` `{ immersive }`; player → `HYPERHUMAN_ORIENTATION_TOGGLE` `{ orientation: 'portrait'|'landscape' }` when Rotate needs top-level `screen.orientation.lock`.

**Usage / quota:** the URL or `HYPERHUMAN_EMBED_INIT` `apiKey` is sent as `X-Api-Key`. Pre-built `GET /workouts/{id}` + `/playlist` (+ `/music`) + session lifecycle, and each JSON `GET /video-asset/{id}`, are ordinary **1x** Content API calls on that key. Host generate/recommend/adapt stay **10x**. There is no separate embed-play unit. Check `GET /v1/orgs/{orgId}/stats` or the team app Analytics → API.

---

## HyperCast (Cast to TV & remote control)

Pair your app with a TV showing the org-branded member-web **`https://member.hyperhuman.cc/cast?orgId=...`** page (QR + 6-digit code, **80-minute** sessions; OpenAPI tag **`workouts-cast`**) and drive playback remotely. The display page owns session create/terminate — controller apps only **join** and **control**. One controller per session (a new join disconnects the previous device). Team-scoped keys may only join/create sessions for their own organization (`403` otherwise). REST commands broadcast to the display server-side (**1x**) — no Socket.IO dependency required. Generate an opaque `deviceId` and reuse it. Omit deprecated `workoutData`. Show `done_exercise` only when status reports `currentExerciseIsRepBased: true`. Cast start attribution is **API key + body fields only** (a member Bearer token does not win). Send `organizationId` with the identifier unless the key is team-scoped. The end user must be an active member of that organization (`404` otherwise — playback does not start). Omit the fields for anonymous. `done` completes the session created at start.

```bash
# 1) Join with the 6-digit code from the TV
curl -s -X POST "https://content.api.hyperhuman.cc/v1/cast/pairing/join-by-code" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"pairingCode":"123456","deviceInfo":{"deviceId":"device-abc","deviceName":"My App","deviceType":"mobile"}}'
# Returns: { success, sessionId, displayName, socketIO: { fullUrl } }

# 2) Load a workout on the display (workoutData is deprecated/optional)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/cast/pairing/$CAST_SESSION/workout" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"workoutId":"'$WID'","deviceId":"device-abc","locale":"en-US"}'

# 3) Start playback - optional attribution (API key + fields only; Bearer does not win)
# Omit endUserId/organizationId for anonymous. Non-member identifier → 404, TV does not play.
curl -s -X POST "https://content.api.hyperhuman.cc/v1/cast/pairing/$CAST_SESSION/workout/start" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"workoutId":"'$WID'","deviceId":"device-abc","endUserId":"user@example.com","organizationId":"'$ORG'"}'

# 4) Control: play | pause | next | prev | restart | done_exercise | done
curl -s -X POST "https://content.api.hyperhuman.cc/v1/cast/pairing/$CAST_SESSION/workout/control" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \
  -d '{"action":"pause","deviceId":"device-abc"}'

# 5) Status polling (no-socket fallback): state, exercise index, chapter cursor
curl -s "https://content.api.hyperhuman.cc/v1/cast/pairing/$CAST_SESSION/workout/status" -H "X-Api-Key: $API_KEY"

# 6) Unpair (playback keeps running; rejoin with the same code)
curl -s -X POST "https://content.api.hyperhuman.cc/v1/cast/pairing/$CAST_SESSION/device/disconnect" \
  -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" -d '{"deviceId":"device-abc"}'
```

**Realtime status (optional):** connect Socket.IO using `socketIO.fullUrl` from the join response (namespace **`/cast/pairing`**) with handshake `auth: { apiKey }` and listen for `device-paired`, `device-disconnected`, `workout-cast`, `workout-session-started`, `workout-state-changed`, `workout-progress-changed` (live class-chapter cursor from the display). `pairing-success` is `{ sessionId, displayName }` — device info arrives on `device-paired`. Emit `heartbeat` `{ sessionId, deviceId }` every 30 s. After a socket drop, poll `GET .../workout/status`. An idle/disconnected controller frees the pairing slot but never interrupts TV playback — only `done` or session termination ends it.

---

## 9) Complete Endpoint Reference

### Workouts
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/workouts/metadata` | Categories & difficulties | API Key |
| GET | `/v1/workouts/equipment/metadata` | Equipment list | API Key |
| GET | `/v1/workouts/exercises/metadata` | Muscle groups & collections | API Key |
| GET | `/v1/workouts/{id}` | Workout details | API Key |
| GET | `/v1/workouts/{id}/similar` | Same-org similar workouts (`limit` default 5, max 10; `visibility` default `public`). Not recommend. **1x** | API Key |
| GET | `/v1/workouts/{id}/playlist` | Video segments & media. `exercise.duration` = work window; loop shorter clips | API Key |
| GET | `/v1/workouts/{id}/music` | PulseMix tracks (persisted workout id only) | API Key |
| GET | `/v1/video-asset/{id}` | Exercise media by id (JSON-receiver path) | API Key |
| GET | `/v1/workouts/{id}/export/video/stream_url` | `text/plain` presigned URL — latest ready full video (404 if none) | API Key |
| GET | `/v1/workouts/{id}/export/audio/stream_url` | `text/plain` presigned URL — narrative audio | API Key |
| GET | `/v1/orgs/{orgId}/workouts` | Org-specific workouts | API Key |
| GET | `/v1/orgs/{orgId}/methodologies` | Team Programming DNA profiles (summary + `ruleSummary` only) | API Key |
| POST | `/v1/orgs/{orgId}/workouts/generate` | AI workout generation (ephemeral JSON → JSON receiver). May include Generation Inspector | API Key |
| POST | `/v1/orgs/{orgId}/workouts/generate/preflight` | Dry-run generate body (no workout created; **1x**; not the Inspector snapshot) | API Key |
| POST | `/v1/orgs/{orgId}/workouts/recommend` | Library recommendations | API Key |
| POST | `/v1/orgs/{orgId}/workouts/{workoutId}/adapt` | Adapt existing workout | API Key |

#### Metadata Endpoints
**`GET /v1/workouts/metadata`**, **`GET /v1/workouts/equipment/metadata`**, **`GET /v1/workouts/exercises/metadata`**, and **`GET /v1/plans/metadata`** support `?locale=` for localized display names (categories, equipment, muscle groups, goals). Ids differ by environment. Equipment category samples such as `67deb36a6a5e49797c122f56` are production. See [Localization](#6-localization) for the 11-locale older rows and for Functional Training, Low-Impact Cardio, Improve Metabolic Health, Menopause Support, Reformer, Sled, Ski Erg, Weighted Vest, and Wall Ball.

#### Organization Workouts Filtering
**`GET /v1/orgs/{orgId}/workouts`** supports comprehensive filtering.

**Query Parameters** (see [OpenAPI](https://content.api.hyperhuman.cc/openapi.json) for the full, validated set):
- `offset` (number, default: `0`) - Skip N items
- `limit` (number, default: **`20`**, max: **`50`**) - Page size
- `visibility` (string) - Filter by visibility: `"private"`, `"public"`, or `"all"` (default: `"all"`)
- `difficulty` (string) - Filter by difficulty: `"beginner"`, `"intermediate"`, or `"advanced"`
- `categoryIds` (string) - Comma-separated category ids (optional `custom:` tokens allowed where documented)
- `tags` (string) - Comma-separated tags. Match **any** tag, case-insensitive exact (`hiit` matches `HIIT`, not `hiit-advanced`). Optional org labels; not categories.
- `duration` (string) - Duration range in format `"min-max"` (e.g., `"15-45"` for 15-45 minutes)
- `q` (string) - **Fuzzy** search on workout name, description, categories, tags, and locale-scoped name translations. While `q` is active: results are **relevance-ordered**, **`sort` is ignored**, and **`links.total`** reflects the post-search count (search runs across the full filtered set, then `offset`/`limit` slice). See [Library search behavior](#library-search-behavior).
- `sort` (string) - Sort fields with `+`/`-` prefix where supported
- `locale` (string) - Preferred language locale (e.g., `"fr-FR"`)
- There is **no** `page` query parameter; prefer **`links.next`** (OpenAPI may hide `offset` on shared list schemas; runtime still accepts it).

**Example:**
```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts?limit=20&visibility=public&q=HIIT" \
  -H "X-Api-Key: $API_KEY"
```

### Plans
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/plans/metadata` | Goals & difficulties | API Key |
| GET | `/v1/plans/{id}` | Plan details | API Key |
| GET | `/v1/plans/{id}/similar` | Same-org similar programs (same query as workout similar). Not recommend. **1x** | API Key |
| GET | `/v1/plans/{id}/workouts` | Plan workout list | API Key |
| GET | `/v1/orgs/{orgId}/plans` | Org-specific plans | API Key |
| POST | `/v1/orgs/{orgId}/plans/generate` | AI plan generation (`id` only with `endUserProfileDetails.endUserId`). May include Generation Inspector | API Key |
| POST | `/v1/orgs/{orgId}/plans/recommend` | Library recommendations | API Key |
| POST | `/v1/orgs/{orgId}/plans/{planId}/adapt` | Adapt plan (**requires** `endUserProfileId`; generated member plans in-scope; active → next slot; inactive catalog copy → 400) | API Key |

### End-user content library
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/endusers/{endUserId}/workouts` | List this org's published workouts in this user's library | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/workouts/assign` | Assign workouts (max 10; empty **200**; optional notify flags) | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/workouts/unassign` | Remove workout access (idempotent; non-org ids ignored) | API Key |
| GET | `/v1/orgs/{orgId}/endusers/{endUserId}/plans` | List org catalog programs in this user's library | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/assign` | Assign programs (max 5; does **not** start the plan) | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/unassign` | Remove plan access (idempotent; non-org ids ignored; active plan row is deleted) | API Key |

Team API key only (`X-Api-Key`). Target must be an **active** organization member (suspended / unknown → **404**).

**Assign** eligibility (all-or-nothing): workouts belong to this org, `status` published, visibility `public` or `private`; plans owned by an org trainer, visibility `public` or `private` (never `end-user-ai-generated`). Any ineligible id → **400** and no writes. Already-assigned ids are skipped (no re-notify). Safe to retry.

**Unassign** is org-owned, not the assign catalog gate: only this organization's workouts and programs are removed. Missing rows and ids that are not this organization's content are ignored (empty **200**, no notification). A later-drafted or retired org workout can still be removed. Unassigning the active plan **deletes** the row (see enrollment `.../plans/end` below).

**Lists:** `GET .../workouts` is this org's **published** assigned workouts (not the org catalog). `GET .../plans` is this org's catalog programs (`public`/`private`) in the library — not `GET .../plans/active`. The shared workout list query may show a `visibility` field; this org list does not apply it.

Notifications (Teams email + Club push) fire on **new** rows only; omit flags to default **true**, still gated by the user's communication preferences. Rate limit **1x**.

**Assign workouts — request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `workoutIds` | string[] (24-character hex) | Yes | 1–10 unique ids. All-or-nothing. |
| `sendAssignmentEmail` | boolean | No | Default `true`. Set `false` when you send your own email. |
| `sendAssignmentPush` | boolean | No | Default `true`. Set `false` to skip Hyperhuman push. |

**Assign programs — request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `planIds` | string[] (24-character hex) | Yes | 1–5 unique ids. All-or-nothing. Does not set `isActive`. |
| `sendAssignmentEmail` | boolean | No | Default `true`. |
| `sendAssignmentPush` | boolean | No | Default `true`. |

```bash
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/workouts/assign" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"workoutIds":["64f1a2b3c4d5e6f7a8b9c0d1"]}'

curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/plans/assign" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"planIds":["60f1b3b3b3b3b3b3b3b3b3b3"],"sendAssignmentEmail":false}'

curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/workouts?limit=20" \
  -H "X-Api-Key: $API_KEY"

curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/workouts/unassign" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"workoutIds":["64f1a2b3c4d5e6f7a8b9c0d1"]}'
```

### Plans (enrollment)
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/start` | Start plan (`restart?`, `autoAdapt?`, `dropMissedAtWeekEnd?`; idempotent when already active) | API Key |
| PATCH | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/active/settings` | Update active plan settings (`autoAdapt?`, `dropMissedAtWeekEnd?`) | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/active/slots/{position}/skip` | Skip any open slot (no completion; flag required; not only the due slot) | API Key |
| GET | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/active` | Get active plan + schedule (`startDate`, `autoAdapt`, `dropMissedAtWeekEnd`) | API Key |
| GET | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/active/progress` | Get plan progress (`skipped` does not count as completed; sessions on/after `startDate` only) | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/plans/{planId}/end` | End active plan | API Key |

#### End-user plans (lifecycle & progress)

Progress is computed from completed workout sessions matched to plan positions in order.

`POST .../plans/start` enrolls (sets `isActive`). It does **not** require a prior `POST .../plans/assign`. Assign only puts the program in the library. `POST .../plans/{planId}/end` clears `isActive` and keeps the library row; `POST .../plans/unassign` deletes the row (and ends enrollment if that plan was active).

**Start plan — request body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `planId` | string (24-character hex) | Yes | Plan to activate |
| `restart` | boolean | No | When `true`, sets `startDate` to now (fresh progress window). When `false`/omitted and this plan is already active, returns existing access **without** changing `startDate`. |
| `autoAdapt` | boolean | No | When `true`, enables automatic next-workout adaptation for this active enrollment. |
| `dropMissedAtWeekEnd` | boolean | No | When omitted, unfinished slots in fully elapsed weeks before `currentWeekNumber` are skipped and do not count as completed. That week stays due after the plan length. A later week still drops once it has elapsed. Send `false` to keep missed workouts due. |

**Active plan settings — `PATCH .../plans/active/settings`**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `autoAdapt` | boolean | No | Enable or disable automatic next-workout adaptation on the active enrollment. |
| `dropMissedAtWeekEnd` | boolean | No | Enable or disable week-end skip of unfinished slots in fully elapsed weeks before the current week. The current week stays due after the plan length. Turning it off keeps skips that already happened. Either field may be sent alone. |

**Progress rules**

Progress is computed on each read. It is not a stored position.

- **`startDate`** on the active enrollment is the progress window. Sessions with `updatedAt` before `startDate` stay in history and do not fill a slot. `restart: true` sets a new `startDate` and clears stored skips.
- A session fills a slot when `status` is `completed`, `progress >= 0.1`, and `updatedAt >= startDate`. `sessions/complete` forces progress to 1, so it counts. `sessions/end` keeps the last patched progress, so an abandon below 0.1 does not fill a slot.
- **`programPosition`** (optional, integer ≥ 0) on `sessions/start`, `sessions/end`, and `sessions/complete` is the `positionInPlan` of the slot the member opened. When that index still holds this workout, the session fills that slot. The oldest such session wins if several name the same index. The value is ignored when it is missing, not a non-negative integer, out of range, or the workout at that index has changed. It cannot complete a different workout id.
- **Without `programPosition`**, the session fills the earliest slot with that workout id that is not already filled by a pinned session and was still open when the session closed. A skipped copy is filled only when no open copy is left. A second completion of the same id fills the next copy. It does not jump to a later copy.
- **`sessions/start`** marks earlier in-progress sessions for that workout completed. Those older sessions keep their last progress and any `programPosition` already stored, and do not receive the new one. One already at progress ≥ 0.1 pins that stored slot when it still matches; otherwise it fills the earliest open copy.
- **`skipped: true`** is not a completion and is omitted from the completion count. It is true only while that slot has no completion. A later completion of that slot clears it. A member skip stays on that position even if the workout in the slot is later replaced.
- With `dropMissedAtWeekEnd` on (the default when the field is omitted), the due slot is the first position that is neither completed nor skipped. `schedule.todaysWorkout` is that slot when the member has not already completed a workout today. It is also absent when no open slot remains. `schedule.programFinished` is `true` once every slot is completed or skipped; `completionPercentage` counts completed slots only. Week-end skips use 7-day buckets from `startDate` and apply when `workoutsPerWeek` is at least 1, only to weeks before `currentWeekNumber`. That week stays due after the plan length. A later week still drops once it has elapsed. Turning the flag off keeps skips that already happened. Explicit `false` makes the due slot the first position that is not completed, and the skip route returns `409`.
- **Member skip** can target any open slot, including one after the due slot. `201` with no body. `400` when the position is not in the program. `404` when there is no active plan. `409` when the flag is off or that slot is already completed or skipped.
- Refetch progress after a completion or a skip. A conditional GET returns **304** with an empty body when the document is unchanged; keep the body you already stored.

**Example — start (enrollment, idempotent)**

```bash
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/plans/start" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"planId":"69d71697f59ff7001ac85526"}'
```

**Example — explicit restart**

```bash
curl -s -X POST "https://content.api.hyperhuman.cc/v1/orgs/$ORG/endusers/$ENDUSER/plans/start" \
  -H "X-Api-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"planId":"69d71697f59ff7001ac85526","restart":true}'
```

**Example — progress response (position keys)**

```json
{
  "data": {
    "0": {
      "completed": true,
      "latestCompletionDate": "2026-06-05T14:53:30.096Z",
      "workoutName": "Week 1, Day 1 - Strength",
      "difficulty": "intermediate",
      "duration": 1874000,
      "categories": ["Strength", "Balance", "Mobility"],
      "workoutId": "69c84fdc3dbefe001af63840",
      "positionInPlan": 0,
      "skipped": false
    }
  }
}
```

When there is no active plan, progress returns `200` with `{ "data": null }`.

> **Units:** `duration` on progress entries (and on `workouts[]` nested in the plan **generate** response) is **milliseconds**. `GET /v1/workouts/{id}`, `GET /v1/plans/{id}/workouts`, and the org workout list report `duration` in **seconds**. Divide by 1000 before comparing.

**Example — active plan / start / end response (plan access record)**

```json
{
  "data": {
    "id": "6a3c1f0d2b7e4a0012f9c001",
    "planId": "69d71697f59ff7001ac85526",
    "endUserId": "6821e6ad9d20276ef3c3c9ec",
    "isActive": true,
    "autoAdapt": false,
    "dropMissedAtWeekEnd": true,
    "startDate": "2026-06-01T08:00:00.000Z",
    "endDate": null,
    "createdAt": "2026-06-01T08:00:00.000Z",
    "updatedAt": "2026-06-01T08:00:00.000Z",
    "plan": { "id": "69d71697f59ff7001ac85526", "name": "8-Week Strength", "difficulty": "intermediate", "durationInWeeks": 8, "workoutsPerWeek": 3 },
    "schedule": { "currentWeekNumber": 2, "completedWorkouts": 3, "totalWorkouts": 24, "completionPercentage": 12.5, "todaysWorkout": { "position": 3, "workoutId": "69c84fdc3dbefe001af63840", "name": "Week 2, Day 1 - Strength" } }
  }
}
```

`planId` is the id for `GET /v1/plans/{id}` and `GET /v1/plans/{id}/workouts`. `plan` and `schedule` are present on `GET .../plans/active` only. The legacy `_id` / `workoutCollectionId` / `traineeId` keys are **deprecated** aliases of `id` / `planId` / `endUserId` — do not read them in new code.

#### Organization Plans Filtering
**`GET /v1/orgs/{orgId}/plans`** supports comprehensive filtering. Lists **`public` / `private`** published plans only — persisted AI plans with visibility **`end-user-ai-generated`** are **not included** (even with `visibility=all`); fetch those via **`GET /v1/plans/{id}`** using the stored id from generate/adapt.

**Query Parameters** (see [OpenAPI](https://content.api.hyperhuman.cc/openapi.json) for the full, validated set):
- `offset` (number, default: `0`) - Skip N items
- `limit` (number, default: **`20`**, max: **`50`**) - Page size
- `visibility` (string) - Filter by visibility: `"private"`, `"public"`, or `"all"` (default: `"all"`) — does **not** surface `end-user-ai-generated`
- `difficulties` (string) - Comma-separated difficulty levels (e.g., `"beginner,intermediate"`)
- `goalIds` (string) - Comma-separated 24-char hex goal ids
- `tags` (string) - Comma-separated tags. Match **any** tag, case-insensitive exact. Optional org labels; not goals.
- `durationInWeeks` (number) - Filter by program duration in weeks (1-24)
- `workoutsPerWeek` (number) - Filter by workouts per week (1-7)
- `sort` (string) - Use `+`/`-` field prefix form where documented (e.g. sortable fields in OpenAPI)
- `q` (string) - **Fuzzy** search on plan name, description, goals, tags, and locale-scoped translations. While `q` is active: **relevance order**, **`sort` ignored**, **`links.total`** = post-search count. See [Library search behavior](#library-search-behavior).
- `locale` (string) - Preferred language locale (e.g., `"fr-FR"`)
- There is **no** `page` query parameter; prefer **`links.next`** (OpenAPI may hide `offset`; runtime still accepts it).

**Example:**
```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans?offset=0&limit=20&visibility=public&difficulties=intermediate&durationInWeeks=8&workoutsPerWeek=3&sort=+name" \
  -H "X-Api-Key: $API_KEY"
```

#### Plan Workouts
**`GET /v1/plans/{id}/workouts`** - Get complete workout list for a plan

**Purpose:** Retrieve all workouts in a specific training plan in their designed sequence.

**Response shape (aligned with the public API):** the same list envelope as other workout lists: **`{ "data": [ ... ] , "links": { "total": N } }`**. For this endpoint, **`data`** contains **all** plan workouts in order (no `links.next`); `links.total` is the number of returned slots. Do not expect a `page` / `pages` object.

Each item adds **`positionInPlan`** (0-based slot). It matches the position keys of `GET .../endusers/{endUserId}/plans/active/progress` and `schedule.todaysWorkout.position`, so join on `positionInPlan`, not on array index or `id` (the same workout id can appear at several positions, and a slot whose workout was deleted is omitted, leaving a gap).

**Example (illustrative):**
```json
{
  "data": [
    {
      "id": "64f1a2b3c4d5e6f7a8b9c0d1",
      "name": "Upper Body Strength",
      "duration": 2700,
      "difficulty": "intermediate",
      "categories": [
        { "id": "615aa3dadbe7140012c59e90", "name": "Strength" }
      ],
      "trainer": {
        "id": "507f1f77bcf86cd799439011",
        "name": "Trainer Name"
      },
      "preview": { "url": "https://...", "poster": "https://...", "thumbnail": "https://..." },
      "createdAt": "2025-09-15T10:30:00.000Z",
      "updatedAt": "2025-09-15T10:30:00.000Z"
    }
  ],
  "links": {}
}
```

**Important Notes:**
- **No server pagination of the workout array** - all workouts in the plan are returned in one response.
- **Order** - Workouts follow the plan’s designed sequence.
- **Query parameters:** optional `?locale=...` for localized strings (see OpenAPI / implementation).
- **Client-side display** - Paginate in your UI if the list is long.

### Exercise Groups (Micro Workouts)
Micro workouts are short, focused exercise collections (circuit or sets) designed for quick sessions. Use these endpoints to surface them in your app.

| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/groups` | List org micro workouts (paginated, visibility filter) | API Key |
| GET | `/v1/orgs/{orgId}/groups/{groupId}` | Micro workout detail (execution config: circuit/sets, rounds, rest) | API Key |
| GET | `/v1/orgs/{orgId}/groups/{groupId}/exercises` | Exercises in micro workout | API Key |

**Execution details:** kind (circuit|sets), rounds, roundBreakDuration, exerciseBreakDuration, exerciseConfig (timeBased: duration, pace, rpe, amrap | repsBased: reps, weightLevel, pace, rpe).

**Group list and detail** both include `organization` (`{ id, name, logo }` or `null` for a stock group). Detail also carries `{ id, name, kind, rounds, roundBreakDuration, exerciseBreakDuration, exerciseConfig, visibility, ... }`. List `rating` on workouts is `null` when the workout has no ratings (do not treat a missing number as zero).

**Publishing:** Set visibility to public in the Teams web app (team.hyperhuman.cc). Public collections appear in Content API list.

#### Organization Groups Filtering
**`GET /v1/orgs/{orgId}/groups`** supports filtering and pagination:

**Query Parameters:**
- `visibility` (string, default: public) - Filter: `public`, `private`, or `all` (all requires API key for same org)
- `offset` (number, default: 0) - Pagination offset
- `limit` (number, default: 10, max: 50) - Items per page. Values above 50 return **400** `ValidationError` (do not send `limit=100`)

**Example:**
```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/groups?visibility=public&limit=20&offset=0" \
  -H "X-Api-Key: $API_KEY"
```

### Sessions
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| POST | `/v1/workouts/{id}/sessions/start` | Begin session (auto-closes previous). Optional body `programPosition` | API Key (+ JWT opt) |
| PATCH | `/v1/workouts/{id}/sessions/{sessionId}` | Update progress with `progressSeconds` | API Key (+ JWT opt) |
| POST | `/v1/workouts/{id}/sessions/end` | End session (body: `{ sessionId, programPosition? }`) | API Key (+ JWT opt) |
| POST | `/v1/workouts/{id}/sessions/complete` | Force 100% completion (body: `{ sessionId, programPosition? }`) | API Key (+ JWT opt) |
| GET | `/v1/workouts/{id}/sessions/recent` | Last in-progress session (resume) | API Key + identity (JWT or `endUserId`/`externalUserId` query) |
| GET | `/v1/workouts/{id}/sessions/{sessionId}` | Session details | API Key + identity (JWT or `endUserId`/`externalUserId` query) |

> **Attribution:** all session and feedback calls accept optional end-user fields (`endUserId` or `externalUserId`, plus `organizationId` for standard/club keys spanning orgs) — body for `POST`/`PATCH`, query for `GET`. Without them a key-only session is anonymous (`traineeId: null`); a member JWT, when present, always wins.

> **Active program slot:** when this workout is one position in the member's active program, send `programPosition` (the `positionInPlan` from `GET /v1/plans/{id}/workouts` or from `GET .../plans/active/progress`) on start, end, and complete. The same value on all three calls keeps the slot if a later call omits it. See **Progress rules** under Plans (enrollment). `sessions/end` below progress `0.1` does not fill a slot. `sessions/complete` does.

### Session Response Structure
```json
{
  "data": {
    "id": "12ab3efd45c67c1234566cd1",
    "createdAt": "2025-01-03T10:00:00.000Z",
    "updatedAt": "2025-01-03T10:05:30.000Z",
    "workoutId": "12ab3efd45c67c1234566cd1",
    "workoutSegmentId": "seg-exercise-2",
    "status": "started",
    "duration": 125.5,
    "progress": 0.42
  }
}
```
| Field | Type | Description |
|-------|------|-------------|
| status | string | `started` or `completed` |
| duration | number | Actual playback time in seconds (from progressSeconds) |
| progress | number | Completion fraction (0-1) |

### Feedback
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/workouts/feedback/options` | Rating scales | API Key |
| POST | `/v1/workouts/{id}/feedback` | Submit feedback | API Key (+ JWT opt) |

### HyperCast (pairing & remote control) — OpenAPI tag `workouts-cast`
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| POST | `/v1/cast/pairing/session` | Create pairing session (display side; hosted `/cast` page owns this) | API Key |
| GET | `/v1/cast/pairing/session/{sessionId}` | Pairing snapshot (code, expiry, connected device, current workout) | API Key |
| DELETE | `/v1/cast/pairing/session/{sessionId}` | Terminate session (display side; controllers unpair via `device/disconnect`) | API Key |
| POST | `/v1/cast/pairing/join` | Join with sessionId + code (QR: `https://member.hyperhuman.cc/cast?sessionId=...&code=...`) | API Key |
| POST | `/v1/cast/pairing/join-by-code` | Join with the 6-digit code only | API Key |
| POST | `/v1/cast/pairing/{sessionId}/workout` | Load a workout on the display (omit deprecated `workoutData`) | API Key |
| POST | `/v1/cast/pairing/{sessionId}/workout/start` | Start playback (optional `endUserId` / `externalUserId` / `organizationId`; Bearer does not attribute; non-member → 404) | API Key |
| POST | `/v1/cast/pairing/{sessionId}/workout/control` | `play`/`pause`/`next`/`prev`/`restart`/`done_exercise`/`done` | API Key |
| GET | `/v1/cast/pairing/{sessionId}/workout/status` | Playback snapshot (polling fallback) | API Key |
| POST | `/v1/cast/pairing/{sessionId}/device/disconnect` | Unpair controller (playback keeps running) | API Key |

### User Management
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/endusers` | List users | API Key |
| GET | `/v1/orgs/{orgId}/endusers/find` | Check user exists | API Key |
| POST | `/v1/orgs/{orgId}/endusers/invite` | Invite users (batch; optional `sendInviteEmail`) | API Key |
| PUT | `/v1/orgs/{orgId}/endusers/suspend` | Suspend user | API Key |
| PUT | `/v1/orgs/{orgId}/endusers/reactivate` | Reactivate user | API Key |
| DELETE | `/v1/orgs/{orgId}/endusers` | Remove user | API Key |

### AI User Insights
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/endusers/{endUserId}/insights/digest` | Daily AI digest | API Key |
| GET | `/v1/orgs/{orgId}/endusers/{endUserId}/insights/pillars/{pillarType}` | Pillar detail | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/insights/health-data` | Batch health data push (max 50 entries: `activity`, `sleep`, `steps`, `body_metrics`, `heart_rate`, `hydration`, `oxygenation`, `scores`) → per-entry results | API Key |
| DELETE | `/v1/orgs/{orgId}/endusers/{endUserId}/insights/health-data/{dataType}/{externalId}` | Delete a pushed entry + recompute the day | API Key |

> Health data push uses the same path as wearable-synced data: entries feed daily metrics, the digest, and pillars. Idempotent on `(dataType, externalId)` — re-POST to correct, DELETE to remove.

### End-User Nutrition (photo logging + direct log)

> Two-step flow: analyze (vision AI estimate, **10x** API calls) → confirm (writes the log, 1x). The pending estimate expires after **1 hour** and is single-use. The photo is processed in memory and discarded; logged entries feed the same day totals, nutrition score, and insights as direct nutrition log and chat logging.

| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/nutrition/photo-log` | Analyze meal photo (multipart `photo`, optional `date`, `hint`) → pending estimate, **10x** | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/nutrition/photo-log/{pendingLogId}/confirm` | Confirm pending estimate (optional `totals` override) → nutrition logged | API Key |
| POST | `/v1/orgs/{orgId}/endusers/{endUserId}/nutrition/log` | Log macros directly (optional `externalId` = idempotent upsert, `source` label) | API Key |
| DELETE | `/v1/orgs/{orgId}/endusers/{endUserId}/nutrition/log/{externalId}` | Delete a logged entry (subtracts from day totals, recomputes score) | API Key |

### Organization
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/metadata` | Branding, module flags, branded-app AI visibility (`ai*Enabled` ≠ API gates) | API Key |
| GET | `/v1/orgs/{orgId}/join-options` | Organization join settings | API Key |
| GET | `/v1/orgs/{orgId}/stats` | Current usage + rate-limit caps (no endpoint breakdown) | API Key |

### Social Drops
| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/drops` | List ready drops (paginated, filterable) | API Key |
| GET | `/v1/orgs/{orgId}/drops/{dropId}` | Drop detail (variants, copy packs) | API Key |
| POST | `/v1/orgs/{orgId}/drops/{dropId}/export` | Export channel bundle (video, cover, copy) | API Key |

### Social publish (scheduled automations)

PATCH ordering, **422** rules, and **`lastRun*`** field semantics are in this cheat sheet and OpenAPI. **`organizationId`** = team id (same as org drops).

| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/social-publish/connected-platforms` | Which of `instagram`, `tiktok`, `youtube`, `facebook` have a stored account id (no secrets) | API Key |
| GET | `/v1/orgs/{orgId}/social-publish` | List automations; response **`{ data, links }`** with pagination **`links.next`**, **`links.total`** | API Key |
| POST | `/v1/orgs/{orgId}/social-publish` | Create automation (**201**, always **draft**). Body matches the public OpenAPI create schema | API Key |
| GET | `/v1/orgs/{orgId}/social-publish/{taskId}` | Get one task + **`lastRunOutcome`**, **`lastErrorSummary`**, **`lastExecutionAt`** | API Key |
| PATCH | `/v1/orgs/{orgId}/social-publish/{taskId}` | Update task fields + optional **`status`**. **No** `POST …/activate` or `…/deactivate` on Content API | API Key |
| DELETE | `/v1/orgs/{orgId}/social-publish/{taskId}` | Soft delete (**204**); does not cancel an in-flight publish run | API Key |

#### Social publish — list query parameters

**`GET /v1/orgs/{orgId}/social-publish`**

| Query | Default | Notes |
|-------|---------|--------|
| `limit` | 20 | 1–50 |
| `offset` | 0 | |
| `status` | — | `draft` or `active` |
| `sourceMode` | — | e.g. `manual_fixed`, `random_ready`, `ai_insights` |
| `sortBy` | `updatedAt` | `updatedAt` or `createdAt` |
| `sortOrder` | `descend` | `ascend` or `descend` |
| `search` | — | Substring on automation **name** (trimmed, non-empty) |

#### Social publish — response extras (every task in list/get/create/patch)

| Field | When set |
|-------|----------|
| **`lastRunOutcome`** | **`eventType`** of the **most recent** execution event for that task (for example `success`, `failed`, `skipped_limit`, and other values returned by the automation — see public OpenAPI / response schema). Omitted if no events. |
| **`lastErrorSummary`** | Only if **`lastRunOutcome`** is **not** `success` — copies **`errorMessage`** from that latest event. |
| **`lastExecutionAt`** | Timestamp of that latest event. |

**Insight:** This is **not** “last failure ever”: after a success, **`lastErrorSummary`** disappears. There is **no** Content API v1 route to list all execution events or to **refresh** stored per-network public post **URLs** — use the **Hyperhuman team app** for that operator workflow.

#### Social publish — PATCH behavior (high level)

- **`{ "status": "draft" }`** — pauses schedule and clears **`nextRunAt`**. Other fields in the same request are applied **after** pause.
- **`{ "status": "active" }`** — applies other fields first, then **422** if the team’s **social publishing** connection (base URL, API key, and **per-platform account ids** required by **`channelPresets`**) is incomplete, then activates (**`nextRunAt`** preserved if already in the future, else computed from **`repeatMode`**). Exact field names match the [public OpenAPI](https://content.api.hyperhuman.cc/openapi.json).
- Field-only PATCH on an **active** task — if **`channelPresets`** / **`primaryChannelPreset`** change, **422** when the new preset set needs a platform that is not connected.

#### Social publish — typical errors

| HTTP | Situation |
|------|-----------|
| **403** | Bad/missing API key; org not allowed; **POST create** when monthly social export cap is exhausted (**same owner/team rule as org drop export**); missing team owner. |
| **404** | Unknown **`taskId`**, wrong org, or deleted task. |
| **422** | Cannot activate or cannot keep task active after preset change — social publishing not configured or missing account id for a required platform. |

**Not on Content API:** OAuth connect, account sync, one-shot **run-now**, execution event lists, link-refresh tools, or per-post URLs on responses — use the **Hyperhuman team app** to **link social accounts** and for operator tooling. Connect networks there first.

### Content Autopilot (scheduled content automations)

Recurring automations that generate, clone, or publish content. Tasks start as `draft`; activate to schedule. AI generation runs count toward the **10x** weighting.

| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| GET | `/v1/orgs/{orgId}/content-autopilot` | List automations (`limit` 1–50, `offset`, `status`, `scope`, `contentType`, `sortBy`, `sortOrder`) | API Key |
| POST | `/v1/orgs/{orgId}/content-autopilot` | Create automation (**201**, `draft`). Required: `name`, `scope`, `contentType`, `difficultyOptions`, `repeatMode` | API Key |
| GET | `/v1/orgs/{orgId}/content-autopilot/{taskId}` | Get one automation | API Key |
| PATCH | `/v1/orgs/{orgId}/content-autopilot/{taskId}` | Update configuration | API Key |
| DELETE | `/v1/orgs/{orgId}/content-autopilot/{taskId}` | Soft delete | API Key |
| POST | `/v1/orgs/{orgId}/content-autopilot/{taskId}/activate` | Activate (start scheduling) | API Key |
| POST | `/v1/orgs/{orgId}/content-autopilot/{taskId}/deactivate` | Pause | API Key |
| POST | `/v1/orgs/{orgId}/content-autopilot/{taskId}/run-now` | Trigger an immediate run | API Key |
| GET | `/v1/orgs/{orgId}/content-autopilot/events` | Execution events across all tasks (`eventType`, `taskId`, `limit`, `offset`) | API Key |
| GET | `/v1/orgs/{orgId}/content-autopilot/{taskId}/events` | Execution events for one task | API Key |

`scope`: `ai_generate` \| `create_similar` \| `publish_existing`. `contentType`: `workout` \| `program`. `repeatMode`: `once` \| `daily` \| `weekly` \| `biweekly` \| `monthly`. Event `eventType`: `success` \| `failed` \| `skipped_limit` \| `skipped_premium_lost`.

### AI Chat (conversational coach)

Conversational AI coach scoped to an end user. `organizationId` is a **body/query field**, not a path param. **Every** request must also include `userEmail` **or** `userExternalUUID` (body for POST, query for GET/DELETE) — omitting both returns `400 BadRequest`. Counts as **10x** API calls. **Pagination is `page`-based** (not offset/cursor). **Entitlement-gated (2026-07-06):** org keys need the team owner's `features.aiCoachChat` (Platform/Ultra); standard apps (Club/members web) need the end user's own club entitlement `features.ai_chat` **or** the org's team plan `aiCoachChat` — otherwise `403`.

| Method | Endpoint | Purpose | Auth |
|--------|----------|---------|------|
| POST | `/v1/chat/conversations` | Start a conversation (`organizationId`, `initialMessage`, `userEmail`\|`userExternalUUID`) | API Key |
| GET | `/v1/chat/conversations` | List conversations (`organizationId`, `userEmail`\|`userExternalUUID`, `page`, `limit` ≤ 100) | API Key |
| GET | `/v1/chat/conversations/{conversationId}/messages` | List messages (`organizationId`, `userEmail`\|`userExternalUUID`, `page`, `limit` ≤ 100) | API Key |
| POST | `/v1/chat/conversations/{conversationId}/messages` | Send a message (`organizationId`, `content`, `userEmail`\|`userExternalUUID`) | API Key |
| POST | `/v1/chat/conversations/{conversationId}/actions/confirm` | Confirm a pending AI action (`organizationId`, `pendingActionId`, `userEmail`\|`userExternalUUID`) | API Key |
| DELETE | `/v1/chat/conversations/{conversationId}` | Archive conversation (`organizationId`, `userEmail`\|`userExternalUUID`) | API Key |

#### Organization Drops Filtering
**`GET /v1/orgs/{orgId}/drops`** supports filtering and pagination:

**Query Parameters:**
- `limit` (number, default: 20, max: 50) - Items per page
- `offset` (number, default: 0) - Pagination offset
- `q` (string, min 3 chars) - Search by drop title
- `sort` (string) - Sort: `+updatedAt`, `-createdAt`, `+createdAt`, `-updatedAt`
- `sourceType` (string) - Filter: `exercise`, `exerciseCollection`, `workout`, `workoutCollection`
- `sourceId` (string) - Filter by source entity ID
- `aspectRatio` (string) - Filter: `9:16`, `1:1`, `16:9`
- `duration` (number) - Filter by duration in seconds: `15`, `30`, `45`, `60`

**Example:**
```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/drops?limit=20&offset=0&sourceType=exercise&aspectRatio=9:16&sort=-updatedAt" \
  -H "X-Api-Key: $API_KEY"
```

---

## 10) Implementation Tips
- **IDs first:** use `workouts/metadata`, `plans/metadata`, `workouts/exercises/metadata` to fetch valid `categoryId`, `goalIds`, and collection names.
- **Organization setup:** use `orgs/{orgId}/metadata`, `orgs/{orgId}/join-options` for branding and settings.
- **Social publish:** call **`…/connected-platforms`** before **`PATCH status:active`** to preflight which networks are linked; handle **422** with a user-facing “connect in Hyperhuman team app” message. Poll **`GET …/social-publish/{taskId}`** for **`lastRunOutcome`** — there is no **`run-now`** or event list on Content API.
- **Plan workouts:** `/plans/{id}/workouts` returns the **entire list** (no server pagination). Paginate client‑side.
- **Sessions:** Always **start → PATCH (with progressSeconds) → end/complete**. `sessionId` is mandatory for PATCH, end, and complete (end/complete require body `{ sessionId }`).
- **Session status:** `started` or `completed` - no pause state tracked server-side
- **Duration tracking:** Send `progressSeconds` in PATCH requests for accurate duration (actual playback time, not wall-clock)
- **End session:** Uses duration from last PATCH; send final PATCH before calling end for accuracy
- **Feedback:** Get options first, submit with IDs (not values)
- **Recommendations:** Use for instant discovery from existing library (fastest, lowest cost)
- **Generation:** Use for unique, personalized content when perfect customization needed
- **Adaptation:** Use to customize existing workouts/plans while preserving original intent
- **Exports vs Playlists:** Use exports for offline/simple playback, playlists for interactive experiences
- **Exercise clip vs segment duration:** Playlist `exercise.duration` is the planned work window. The MP4 is often shorter; loop it (`main.video.loop: -1`) until that clock elapses. Do not use file length as the timer.
- **Caching:** Cache metadata & feedback options; refresh on locale change or app start.
- **Rate limits:** Respect `X-RateLimit-Limit | Remaining | Reset`; back off on `429/5xx`.
- **Locales:** pass `locale` when rendering non‑English UI strings.
- **AI Insights:** Returns 3-5 pillars; `health_pulse` always first; `body` only when real body data exists. Digest-level `highlights` (max 8) and per-pillar `insights` (max 3) carry detected signals (bests, strength PRs, milestones, streaks, pace, load, sleep / HR trends, steps, weight trend, logged activities, CTAs); signal `kind`s are additive — render unknown kinds generically. Optional digest fields `readiness`, `week`, `variant`, `shareText`, `dataProfile` and per-pillar `trend7d` let you build a Today header without extra calls. The `movement` pillar reflects logged wearable activities (runs/rides) when available. Digests are cached per **user-local** calendar day (end-user profile time zone, else UTC); `forceRefresh=true` regenerates. After user actions (workout complete, meal log, wearable connect) pass `forceRefresh`.
- **Coaching signals:** Use `readinessBand` for intensity guidance; check `riskLevel` for fatigue warnings; show `why` for transparency.
- **Export URLs:** Cache for up to 6 days; refresh before expiration; handle 404s (video may still be processing).
- **Pillar detail:** Training returns `formCue`, `pacingStatus`, `alternativeActions`; Recovery returns `hrvBand`, `readinessMessage`.
- **User identifiers:** Insights accept email, externalUUID, or internal user ID as `endUserId`.
- **Status colors:** `green` (on track), `yellow` (watch), `red` (priority action needed).
- **Social drops:** List returns only `ready` drops. Use export for one-click channel bundle (video, cover, copy). Presigned URLs valid 7 days.
- **Social weekly plan:** removed. Use social-publish automations with **`repeatMode`** / **`nextRunAt`** instead.
- **Exercise library:** `GET /v1/orgs/{orgId}/video-assets` — **workspace-only** list (no stock / pay-as-you-go; no `visibility` filter); **`trainer`** = creator; **`single-exercise`** rows expose **`audioInstructions[]`** for one locale (`?locale=` narrows; omitted `locale` is `en-US`). `GET /v1/video-asset/{id}` is the same: omitted `locale` is `en-US` instruction and cue audio, not every language. Generate/adapt playback must send the member locale on that GET or on the JSON-receiver URL. Filters: `q` (**substring**, locale-aware — not fuzzy), `equipmentIds`, `muscleGroupIds`, `kinds`, `skillLevels`, `executionSides`, `coach`, `collectionNames`. Sort by `date` / `name` / `kind` (`+`/`-` prefix); default `-date`. Org **workouts/plans** `q` uses **fuzzy** search instead — see [Library search behavior](#library-search-behavior).
- **Micro workouts:** Exercise groups are micro workouts. Use for quick workout browsing, white-label apps, or integration with workout generation (`includeExerciseCollections` / `excludeExerciseCollections`).

---

## 11) Errors & Rate Limiting

### HTTP Status Codes
| Code | Meaning | Action |
|---|---|---|
| 200/201 | OK/Created | Continue. AI recommend/generate/adapt return **200**. **201** is for creates such as sessions start, enduser invite, chat create, autopilot/social-publish create |
| 400 | Bad Request | Validate payload & required fields. Field validation failures use `error.code: ValidationError` with `error.details[]`. Plan adapt without `endUserProfileId`, or inactive adapt of a catalog-derived end-user copy → 400 |
| 401 | Unauthorized | Check `X-Api-Key` |
| 403 | Forbidden | Ensure API key belongs to `organizationId`, or the plan includes the route (stock catalog is Ultra annual / custom). Plan-gated stock **403 does not consume** quota — do not retry |
| 404 | Not Found | Verify IDs. Plan/workout adapt of other-org content → 404 (existence is not leaked) |
| 409 | Conflict | Use existing resource or resolve |
| 422 | Unprocessable Entity | Resolve a business precondition (e.g. connect social accounts), then retry |
| 429 | Rate Limited | Stop; retry after reset (backoff) |
| 5xx | Server Error | Retry with exponential backoff |

### Error Response Structure
Every non-2xx response is wrapped in a single `error` envelope (`{ "error": { "code", "message", "target?", "details?" } }`). There is no top-level `statusCode` field. `error.code` is the stable machine-readable identifier - switch on it, not on `message`.
```json
{
  "error": {
    "code": "ValidationError",
    "message": "ValidationError",
    "target": "GetWorkoutsByOrganizationIdQuery",
    "details": [
      {
        "code": "ConstraintError",
        "message": "difficulty must be a valid enum value",
        "target": "difficulty"
      }
    ]
  }
}
```

**Common Error Examples (verified against live responses):**

**400 Validation Error** (field/query/path validation; `error.details[]` lists each offending field):
```json
{ "error": { "code": "ValidationError", "message": "ValidationError", "target": "GetUserDigestQuery", "details": [ { "code": "ConstraintError", "message": "date must be a ISOString", "target": "date" } ] } }
```

**401 Unauthorized** (missing or invalid `X-Api-Key`):
```json
{ "error": { "code": "Unauthorized", "message": "Unauthorized" } }
```

**401 Unauthorized** (key exists but is **locked/disabled** — permanent until an admin re-enables it; do not retry):
```json
{ "error": { "code": "Unauthorized", "message": "API key is not authorized" } }
```

**403 Forbidden** (API key not authorized for the organization in the path):
```json
{ "error": { "code": "Forbidden", "message": "API key is not authorized for this organization" } }
```

**404 Not Found:**
```json
{ "error": { "code": "NotFound", "message": "Not Found" } }
```

**422 Unprocessable Entity** (business precondition not met; plain message, no `details`):
```json
{ "error": { "code": "UnprocessableEntity", "message": "No connected account for platform(s): instagram. Connect those networks in the Hyperhuman team app before activating this schedule." } }
```

**429 Too Many Requests:** returned either when a rate limit is exceeded (`message: "Hourly rate limit exceeded"` / `"Daily rate limit exceeded"`) or when a **locked/disabled key is retried too aggressively** (`message: "Too many requests with an unauthorized API key"`). Both carry rate-limit headers; the locked-key case also sends **`Retry-After`** (seconds). Honor `Retry-After`/`X-RateLimit-Reset` and back off; for the locked-key case, stop retrying entirely until the key is re-enabled.
```json
{ "error": { "code": "TooManyRequests", "message": "Too many requests with an unauthorized API key" } }
```

**Retry helper (JS)**
```js
async function retryWithBackoff(fn, max=3){
  for(let i=0;i<max;i++){
    try { return await fn(); }
    catch(e){ if(e.status===429 || e.status>=500){ await new Promise(r=>setTimeout(r, (2**i)*1000)); continue; } throw e; }
  }
  throw new Error('Max retries exceeded');
}
```

---

## 12) JS Fetch Snippets
**List workouts** (org catalog — there is no `GET /v1/workouts` list)
```js
const res = await fetch(`https://content.api.hyperhuman.cc/v1/orgs/${ORG}/workouts?limit=10`, { headers: { 'X-Api-Key': API_KEY }});
const data = await res.json();
```
**Generate workout** (ephemeral body — no `id`; do not playlist)
```js
const r = await fetch(`https://content.api.hyperhuman.cc/v1/orgs/${ORG}/workouts/generate`, {
  method:'POST',
  headers:{'X-Api-Key':API_KEY,'Content-Type':'application/json'},
  signal: AbortSignal.timeout(120000),
  body: JSON.stringify({
    endUserProfileDetails:{age:28,fitnessLevels:['beginner']},
    exerciseSources:['premium_stock'],
    durationOptions:{minMinutes:20,maxMinutes:20},
    categoryId:'615aa3f0dbe7140012c59e94',
    userRequirements:'CRITICAL: USE up to 3 unique exercises in the whole workout.'
  })
});
const workout = await r.json();
```
**Get AI Insights Digest**
```js
const digest = await fetch(`https://content.api.hyperhuman.cc/v1/orgs/${ORG}/endusers/${encodeURIComponent(ENDUSER)}/insights/digest`, {
  headers: { 'X-Api-Key': API_KEY }
}).then(r => r.json());
// digest.pillars, digest.todaysFocus, digest.dataCompleteness
// optional: digest.readiness, digest.week, digest.variant, digest.shareText, digest.highlights
```

---

## 13) Support & Additional Resources

### API Documentation
- **Swagger UI:** [https://content.api.hyperhuman.cc/docs/api-explorer](https://content.api.hyperhuman.cc/docs/api-explorer)
- **OpenAPI spec:** [https://content.api.hyperhuman.cc/openapi.json](https://content.api.hyperhuman.cc/openapi.json)
- **Agent guide:** [https://content.api.hyperhuman.cc/AGENTS.md](https://content.api.hyperhuman.cc/AGENTS.md)
- **Support chat (go-to):** [https://hyperhuman.cc/?ask=1](https://hyperhuman.cc/?ask=1) or Teams Home **Chat**
- **Contact (Enterprise / custom):** [https://hyperhuman.cc/contact](https://hyperhuman.cc/contact)
- **Email (Enterprise pricing/scoping; major outages):** `support@hyperhuman.cc`
- **Embed player:** `team.hyperhuman.cc/embed/...`

Treat **`/openapi.json`** as the public contract. Routes not listed there are not part of the supported Content API.

---

## 14) AI User Insights

AI-powered daily health and fitness digests for end users. Returns 3-5 personalized insight pillars with coaching signals. Works for end users **without a wearable** too (`dataProfile: "workout_only"` — training signals such as strength PRs, weekly streaks and load come from in-app workouts).

### Digest-Level Fields (additive, all optional)
| Field | Description |
|-------|-------------|
| `dataProfile` | `wearable` or `workout_only`. `dataCompleteness` is weighted per profile, so an engaged app-only user is not scored "incomplete" for lacking a device |
| `variant` | `daily`, `week_ahead` (user-local Monday: planned sessions + one goal) or `week_review` (user-local Sunday: sessions, finishes, best moment) |
| `readiness` | `{ band: push \| maintain \| recover, recommendedIntensity: light \| moderate \| high, message, contributors?: string[] }` — same call as the `health_pulse` pillar, surfaced once for a Today header |
| `week` | `{ completed, planned, status: ahead \| on_track \| behind \| no_target, daysRemaining, weeklyStreakWeeks }` for the current user-local Monday-based week |
| `shareText` | One shareable line (top celebration of the day, or the Sunday review). Absent when nothing is worth sharing |
| `locale` / `timeZone` | Locale the copy was written in and the IANA zone used for "today", streaks and the calendar week |
| pillar `trend7d` | 7 user-local days of the pillar's primary metric (health_pulse score, sleep score, physical score or steps, body score or weight, nutrition score), oldest first, `null` for missing days |
| pillar `why` | Up to 3 short reasons, now on every pillar when data allows (was training / recovery only) |

**Day boundaries and caching:** all "today" / streak / week math and the digest cache key use the end user's profile time zone (set in the member app profile; IANA, e.g. `Europe/Bucharest`), else UTC. A digest is cached for that user-local calendar day; `forceRefresh=true` regenerates it. Copy language follows the end user's `preferredLanguage`; when the AI step fails, the fallback digest is English.

### Pillar Types
| Type | Description |
|------|-------------|
| `health_pulse` | Overall health status + readiness band (always first) |
| `training` | Workout progress, form coaching, pacing, risk detection |
| `recovery` | Sleep, recovery readiness, HRV band, why explanations |
| `movement` | Daily activity and cardio; grounded in **logged wearable activities** (runs/rides/swims) when available, otherwise the wearable activity score |
| `nutrition` | Nutrition quality (when available) |
| `body` | Body metrics and momentum (**conditional** - only when wearable body score or recent weight entries exist) |

### Coaching Signal Fields
- **readinessBand:** `push` | `maintain` | `recover` - training capacity for today
- **riskLevel:** `low` | `moderate` | `high` - fatigue/burnout warning
- **riskTriggers:** Array of up to 3 factors (e.g., "Sleep declining", "High RPE")
- **formCue:** Primary technique focus; **formSuggestion:** adjustment if declining
- **pacingStatus:** `on_track` | `behind` | `ahead` - calendar-week progress vs planned training days. Machine status only. `pacingMessage` on a short week is `"{n} done this week. Another session still counts."` or `"{days} left. One session keeps the week alive."`, not the word Behind
- **why:** Array of reasons explaining recommendation; **alternativeActions:** safe options

### Detected Signals (`highlights` / `insights`)
Digest-level `highlights` (max 8) and per-pillar `insights` (max 3) carry detected signals:
- **kind:** `streak_at_risk` | `personal_best` | `milestone` | `adherence_trend` | `pace` | `recovery_debt` | `activity_logged` | `activity_volume` | `day_of_week_pattern` | `time_of_day_pattern` | `correlation` | `plateau` | `cta` | `ai_pattern`, plus (2026-09) `weekly_streak` | `strength_pr` | `load` | `completion_depth` | `program_milestone` | `variety` | `progression` | `onboarding` | `rest_day` | `form` | `plan_adapted` | `sleep` | `sleep_regularity` | `rhr_trend` | `hrv_trend` | `strain` | `steps` | `hydration` | `weight_trend` | `week_ahead` | `week_review` (`readiness_contributor` is reserved; contributors arrive on `digest.readiness.contributors`)
- **Kinds are additive.** New kinds may appear without a version bump — render any unknown `kind` generically (`label` + optional `detail`), never filter it out
- **copyKey** (optional): stable template id such as `streak_at_risk.workout` or `pace.complete` — key your own copy tables / analytics on it, not on the English `label`
- **priority:** 1 (highest) - 7; **celebration:** worth celebrating in UI; **isNewToday:** first fired today
- Labels are short and numbers-first, e.g. `5-day streak on the line. Train today.`, `New best: Back Squat 95 kg x 3.`, `4 weeks in a row. That is a habit.`, `Heavy week: 60% above your usual load.`, `5h 40m sleep. Keep today light.`, `Resting HR up 6 bpm this week. Go easy.`, `8,200 steps a day, up 24% on last month.`, `Weight trending down 0.8 kg over 4 weeks.`
- **source:** `deterministic` (computed) or `ai` (model-proposed pattern - phrase softly in UI, never as a hard fact)
- **label** is the short line; **detail** is optional evidence. On expanded pillar views, **wrap** `label`/`detail` — do not character-slice them. Glance/collapsed surfaces may clamp with line limits.
- A `[CTA]` signal is often copied into that pillar's `action` and still listed on `insights`. Show the action once; hide the `insights` row when it only repeats `headline`/`action` unless `detail` adds a new fact.
- Personal bests / milestones appear **only on the day they occur**; `streak_at_risk` and CTAs appear on current-day digests only
- `activity_logged` (homed on `movement`, celebration) fires when a wearable activity was logged today; `activity_volume` summarizes the week's logged sessions + active minutes. Both are digest / Health HQ Patterns signals — they do not drive the daily insights push
- The nutrition-tracking CTA label is **Snap one meal today**. It is frequency-capped server-side (Mondays and Thursdays). Do not add a second client weekday cap.
- Weekday-gap example: `No Monday workouts in 3 weeks.` (detail holds the 90-day prior count). Recovery debt example: `3 low-recovery days. Keep today light.`
- `strength_pr` / `weekly_streak` / `load` / `completion_depth` / `program_milestone` / `onboarding` come from in-app workout data only (no wearable needed); `sleep` / `sleep_regularity` / `rhr_trend` / `hrv_trend` / `strain` / `steps` / `hydration` / `weight_trend` need wearable summaries (or the matching health-data pushes: `steps`, `hydration`, `body_metrics`, `sleep`, `heart_rate`). `load` is descriptive (light / steady / heavy vs the 4-week average), never an injury-risk score

### Pillar Detail Example (Training)
```json
{
  "type": "training",
  "status": "yellow",
  "headline": "0% this fortnight. 3 sessions in 6 days.",
  "action": "Book cardio for tomorrow",
  "recommendedIntensity": "moderate",
  "riskLevel": "moderate",
  "riskTriggers": ["Sleep declining", "RPE high"],
  "formCue": "Keep elbows tucked during presses",
  "pacingStatus": "behind",
  "why": ["Sleep declining", "RPE high", "Form declining"],
  "alternativeActions": ["Moderate intensity", "Swap to Mobility"]
}
```

### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `date` | ISO date | today | Date for the digest |
| `forceRefresh` | boolean | `false` | Bypass the server digest cache and regenerate |

### User Identifier Formats
The `endUserId` parameter accepts:
- Email: `john.doe@acme.com`
- External UUID: `ext-123`
- Internal user ID: `507f1f77bcf86cd799439011`

All three resolve to the same end user. An identifier that does not resolve to an end user in the organization returns **`404 Not Found`** (the server never errors on an unknown identifier). On `GET .../endusers/find`, a malformed `email` value is rejected with **`400 ValidationError`**.

---

## Library search behavior

How text search works on **library list** endpoints (workouts, plans, exercises).

### Two modes on Content API

| Endpoint | Param | Engine | Sort while searching |
|----------|-------|--------|----------------------|
| `GET /v1/orgs/{orgId}/workouts` | `q` | **Fuzzy** (relevance) | **Ignored** — omit `sort` in clients |
| `GET /v1/orgs/{orgId}/plans` | `q` | **Fuzzy** (relevance) | **Ignored** |
| `GET /v1/orgs/{orgId}/video-assets` | `q` | **Substring** (DB regex) | Normal sort rules |

### Fuzzy lists (`q` on org workouts / plans)

1. Apply DB filters (visibility, difficulty, categories, …).
2. Load **all** matching rows (not just the current page).
3. Apply any documented in-memory filters (e.g. custom `categoryIds`, duration range).
4. **Fuzzy rank** with locale-scoped translation keys (`?locale=` recommended).
5. **`slice(offset, offset + limit)`** for the response page.
6. **`links.total`** = count after step 3–4, not the unfiltered DB total.

**Client checklist:**

- Pass **`locale`** when showing localized names so search matches the same translations.
- **Do not send `sort`** while the user is typing a search query (browse sort resumes when `q` is cleared).
- Use **`links.next`** with the same `q` and `offset`/`limit` — do not use `page`.

**Example — search org workouts by name:**

```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/workouts?limit=20&offset=0&q=plank&locale=en-US" \
  -H "X-Api-Key: $API_KEY"
```

**Example — search org plans:**

```bash
curl -s "https://content.api.hyperhuman.cc/v1/orgs/$ORG/plans?limit=20&offset=0&q=strength&locale=de-DE" \
  -H "X-Api-Key: $API_KEY"
```

### Not library fuzzy search

- **End-user list** search — substring on profile fields.

---

## Organization exercise library (Smart Library)

**What you get:** paginated **published** exercise videos for `orgId` — previews and metadata for catalogs, builders, and players. **`trainer`** = creator (workspace member, legacy key).

**Workspace-only contract:** this endpoint exclusively lists assets the workspace recorded itself. Hyperhuman stock and pay-as-you-go assets are never returned here (they ship as fuel for full-length AI workouts under separate licensing). There is **no `visibility` query parameter** — the workspace editor publishes catalog assets as public by default.

**`kind`:** **`single-exercise`** — smart clip; guided sessions use **instruction/cue audio per locale** (primary guide is not the raw studio track). **`multi-exercise`** — usually **original audio**; product **Original** maps here, often with **`playsInLoop: false`**. **`educational`** | **`promo`** | **`class`** — tutorial, branded short, or class session. No separate **`original`** enum. For **`class`**, optional exercise **chapters** (timestamped blocks within the video) are returned on the workout **playlist** (`GET /v1/workouts/{id}/playlist`) when chapter analysis has been run — not on this list endpoint.

**`audioInstructions[]`:** populated only for `kind: single-exercise`. `?locale=` selects one language via the standard locale chain (English fallback). Omitted `locale` is **`en-US`**, not every language. Request each locale when a catalog needs a "narrated in N languages" count. Other kinds keep their original audio baked into the video and return `[]`. Each item: `id`, `createdAt`, `assetUri` (presigned MP4A — short-lived, do not cache long-term), `type` (typically `exerciseInstruction`), `locale`, and **`scriptText`** — the verbatim transcript the narrator reads. Use `scriptText` for closed captions, accessibility, search indexing, and AI summarization. **Optional**: legacy or stock recordings without a stored script omit the field — always null-check.

| Query | Notes |
|-------|------|
| `offset`, `limit` | Service default **`limit` 10** when omitted; max **50** — use `links.next` |
| `locale` | Optional BCP-47. Localizes `name`, `muscleGroups[].name`, `equipment[].name`, and **`audioInstructions[]`** for `single-exercise` rows (chain fallback to English). Omitted `locale` is **`en-US`**. `GET /v1/video-asset/{id}` returns instruction and cue audio for the same rule (omitted `locale` is `en-US`). Detail names on that GET stay the stored strings |
| `q` | Case-insensitive **substring** on `name` (not fuzzy). With `?locale=`, also matches `nameTranslations.<locale>`. Sort rules **still apply** while searching. Org workout/plan lists use **fuzzy** `q` instead — see [Library search behavior](#library-search-behavior). |
| `equipmentIds` | CSV of 24-hex equipment ids — assets whose `equipment[]` includes ANY id |
| `muscleGroupIds` | CSV of 24-hex muscle-group ids — assets whose `muscleGroups[]` includes ANY id |
| `kinds` | CSV of `single-exercise`, `multi-exercise`, `educational`, `promo`, `class` |
| `skillLevels` | CSV of `beginner`, `intermediate`, `advanced` |
| `executionSides` | CSV of `both`, `left`, `right`, `alternating` |
| `coach` | Single value: `male` / `female` |
| `collectionNames` | CSV of exact (case-sensitive) collection names; rows with empty `collectionName` excluded when set |
| `sort` | `+`/`-` prefix, comma-separated. Allowed: `date` (alias of `createdAt`), `name`, `kind`. Default `-date`. Example: `-kind,+name` |

**Filtering & sorting semantics:** all filters AND-combine; a multi-value filter matches **any** listed value. Applied before pagination so `links.total` stays exact regardless of filter combination. Bad values (non-hex ids, unknown enum members, empty CSV, unknown sort field) return `400 ValidationError` with the offending field in `error.target`.

```json
{
  "data": [{
    "id": "60812dea606fc90011eb4405",
    "kind": "single-exercise",
    "trainer": { "id": "63a1c2b4f0e1a200126616a1", "name": "Sarah Johnson" },
    "preview": { "url": "..." },
    "audioInstructions": [
      { "id": "65a1b2c3d4e5f600126616a1", "type": "exerciseInstruction", "locale": "en-US", "assetUri": "https://hhcontent.s3.eu-central-1.amazonaws.com/audio/inst-60812dea-en-US.m4a?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...", "scriptText": "Stand tall with your feet shoulder-width apart. Lower into a squat, keeping your chest up." },
      { "id": "65a1b2c3d4e5f600126616a2", "type": "exerciseInstruction", "locale": "fr-FR", "assetUri": "https://hhcontent.s3.eu-central-1.amazonaws.com/audio/inst-60812dea-fr-FR.m4a?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...", "scriptText": "Tenez-vous droit, pieds écartés à la largeur des épaules. Descendez en squat en gardant la poitrine haute." }
    ]
  }],
  "links": { "next": null, "total": 142 },
  "explanation": "Showing 142 published exercise videos from your workspace."
}
```

---

## Exercise Groups (Micro Workouts)

Short, focused exercise collections (circuit or sets) for quick sessions. Use for white-label apps, quick workout browsing, or integration with workout generation (`includeExerciseCollections` / `excludeExerciseCollections`).

| Endpoint | Purpose |
|----------|---------|
| `GET /v1/orgs/{orgId}/groups` | List org micro workouts (paginated, visibility filter) |
| `GET /v1/orgs/{orgId}/groups/{groupId}` | Full detail with execution config (circuit/sets, rounds, rest) |
| `GET /v1/orgs/{orgId}/groups/{groupId}/exercises` | Exercises in micro workout with instance config |

**Use cases:** White-label apps, quick workout browsing, circuit/sets-based collections, include/exclude exercise collections in workout generation.

**Execution config:** kind (circuit|sets), rounds, roundBreakDuration, exerciseBreakDuration, exerciseConfig (timeBased: duration, pace, rpe, amrap | repsBased: reps, weightLevel, pace, rpe).

### List Groups Response Structure
```json
{
  "data": [
    {
      "id": "507f1f77bcf86cd799439011",
      "createdAt": "2025-09-15T10:30:00Z",
      "updatedAt": "2025-09-16T14:20:00Z",
      "name": "Quick HIIT Circuit",
      "kind": "circuit",
      "preview": {
        "url": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/group-507f1f77.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...",
        "poster": "https://hhcontent.s3.eu-central-1.amazonaws.com/thumbnails/group-507f1f77.jpg",
        "thumbnail": "https://hhcontent.s3.eu-central-1.amazonaws.com/thumbnails/group-507f1f77.jpg"
      },
      "rounds": 3,
      "roundBreakDuration": 10000,
      "exerciseBreakDuration": 0,
      "exerciseConfig": {
        "timeBasedConfig": { "kind": "timeBased", "duration": 30000, "pace": "medium", "rpe": 7, "amrap": false }
      },
      "trainer": { "id": "trainer123", "name": "Sarah Johnson" }
    }
  ],
  "links": {
    "next": "https://content.api.hyperhuman.cc/v1/orgs/507f1f77bcf86cd799439011/groups?limit=10&offset=10",
    "total": 24
  },
  "explanation": "Your organization micro workouts - circuit and sets-based collections for quick sessions."
}
```

### Group Detail Response Structure
```json
{
  "data": {
    "id": "507f1f77bcf86cd799439011",
    "createdAt": "2025-09-15T10:30:00Z",
    "updatedAt": "2025-09-16T14:20:00Z",
    "name": "Quick HIIT Circuit",
    "kind": "circuit",
    "preview": {
      "url": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/group-507f1f77.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...",
      "poster": "https://hhcontent.s3.eu-central-1.amazonaws.com/thumbnails/group-507f1f77.jpg",
      "thumbnail": "https://hhcontent.s3.eu-central-1.amazonaws.com/thumbnails/group-507f1f77.jpg"
    },
    "rounds": 3,
    "roundBreakDuration": 10000,
    "exerciseBreakDuration": 0,
    "exerciseConfig": {
      "timeBasedConfig": { "kind": "timeBased", "duration": 30000, "pace": "medium", "rpe": 7, "amrap": false }
    },
    "trainer": { "id": "trainer123", "name": "Sarah Johnson" },
    "organization": {
      "id": "507f1f77bcf86cd799439011",
      "name": "My Gym",
      "logo": { "url": "https://hhcontent.s3.eu-central-1.amazonaws.com/logos/org-507f1f77bcf86cd799439011.png" }
    }
  }
}
```

### Group Exercises Response Structure
```json
{
  "data": [
    {
      "id": "60812dea606fc90011eb4405",
      "createdAt": "2025-09-15T10:30:00Z",
      "name": "Jumping Jacks",
      "kind": "single-exercise",
      "muscleGroups": [{ "id": "mg_full_body", "name": "Full Body" }],
      "equipment": [{ "id": "eq_bodyweight", "name": "Bodyweight" }],
      "trainer": { "id": "trainer123", "name": "Sarah Johnson" },
      "preview": {
        "url": "https://hhcontent.s3.eu-central-1.amazonaws.com/example/ex-60812dea.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Signature=...",
        "poster": "https://hhcontent.s3.eu-central-1.amazonaws.com/thumbnails/ex-60812dea.jpg",
        "thumbnail": "https://hhcontent.s3.eu-central-1.amazonaws.com/thumbnails/ex-60812dea.jpg"
      },
      "duration": 30,
      "reps": null,
      "pace": "medium",
      "weightLevel": null,
      "rpe": 7,
      "amrap": false
    }
  ],
  "links": { "next": null, "total": 5 }
}
```

---

## Social Drops

Publish-ready packages (video variants, covers, copy packs) for social channels. Drops are linked to Exercises, Collections, Workouts, or Programs.

| Endpoint | Purpose |
|----------|---------|
| `GET /v1/orgs/{orgId}/drops` | List ready drops (paginated, filter by sourceType, aspectRatio, duration, search) |
| `GET /v1/orgs/{orgId}/drops/{dropId}` | Full detail with video variants (presigned URLs) and copy packs per channel |
| `POST /v1/orgs/{orgId}/drops/{dropId}/export` | One-click channel bundle: video download URL, cover image URL, copy pack |

**Use cases:** Social scheduling tools, content calendars, bulk export to Reels/Shorts/TikTok.

**Channel presets:** `ig_reels`, `ig_stories`, `ig_carousel`, `tiktok`, `yt_shorts`, `fb_reels`, `yt_longform`.

**Note:** Only drops with status `ready` are returned. Presigned URLs valid 7 days.

### Social publish (scheduled automations)

Content API is a **narrow** integration surface: schedule CRUD + read-only connected-platform hint + **`lastRun*`** on tasks.

| Endpoint | Purpose |
|----------|---------|
| `GET /v1/orgs/{orgId}/social-publish/connected-platforms` | `connectedPlatforms[]` — platforms with a non-empty stored account id |
| `GET /v1/orgs/{orgId}/social-publish` | Paginated **`{ data, links }`**; optional **`status`**, **`sourceMode`**, **`sortBy`**, **`sortOrder`**, **`search`** |
| `POST /v1/orgs/{orgId}/social-publish` | **201**; always **draft**; **403** if monthly social export limit already hit for team owner |
| `GET /v1/orgs/{orgId}/social-publish/{taskId}` | Full task + **`lastRunOutcome`**, **`lastErrorSummary`**, **`lastExecutionAt`** |
| `PATCH /v1/orgs/{orgId}/social-publish/{taskId}` | Public OpenAPI update fields + optional **`status`**; **`active`** → **422** if posting is not ready; **`draft`** clears **`nextRunAt`** |
| `DELETE /v1/orgs/{orgId}/social-publish/{taskId}` | **204** soft delete |

**Insights for integrators**

- **Confirmation without URLs:** treat **`lastRunOutcome === "success"`** as posted; no post id or per-network URLs in Content API responses.
- **Debugging failures:** **`lastErrorSummary`** mirrors the latest non-success event’s **`errorMessage`** (e.g. export limit, missing drop, downstream publish errors). If the **latest** run succeeded, the summary field is omitted even if older failures exist.
- **Preset → platform:** multiple channel-preset values map to one outbound network (e.g. **`ig_reels`** and **`ig_stories`** → **`instagram`**); one **connected** `instagram` account id satisfies every preset that targets that network.
- **Weekly plan removal:** do not call **`…/social-weekly-plan`** — it was removed. Use automations + **`repeatMode`** / **`nextRunAt`** instead.

### List Drops Response Structure
```json
{
  "data": [
    {
      "id": "64f1a2b3c4d5e6f7a8b9c0d1",
      "organizationId": "507f1f77bcf86cd799439011",
      "sourceType": "exercise",
      "sourceId": "507f1f77bcf86cd799439011",
      "sourceName": "Jumping Jacks",
      "title": "Jumping Jacks",
      "sourceThumbnailUrl": "https://hhcontent.s3.eu-central-1.amazonaws.com/...",
      "sourceTypeLabel": "Exercise",
      "locale": "en-US",
      "channelPreset": "ig_reels",
      "channelPresetLabel": "Instagram Reels",
      "availableVariants": ["9:16_30000", "9:16_15000", "1:1_30000"],
      "createdAt": "2025-09-15T10:30:00Z",
      "updatedAt": "2025-09-16T14:20:00Z"
    }
  ],
  "links": {
    "next": "https://content.api.hyperhuman.cc/v1/orgs/507f1f77bcf86cd799439011/drops?limit=20&offset=20",
    "total": 42
  },
  "explanation": "Your ready social drops - video variants, covers, and copy packs for channel publishing."
}
```

### Drop Detail Response Structure
```json
{
  "id": "64f1a2b3c4d5e6f7a8b9c0d1",
  "organizationId": "507f1f77bcf86cd799439011",
  "sourceType": "exercise",
  "sourceId": "507f1f77bcf86cd799439011",
  "sourceName": "Jumping Jacks",
  "title": "Jumping Jacks",
  "sourceThumbnailUrl": "https://hhcontent.s3.eu-central-1.amazonaws.com/...",
  "sourceTypeLabel": "Exercise",
  "locale": "en-US",
  "channelPreset": "ig_reels",
  "channelPresetLabel": "Instagram Reels",
  "availableVariants": ["9:16_30000", "9:16_15000", "1:1_30000"],
  "copyPack": {
    "hook": "Get fit in 30 seconds!",
    "caption": "30 seconds of Jumping Jacks to get your heart pumping.",
    "hashtags": "#fitness #workout #hiit #cardio",
    "cta": "Link in bio",
    "pinnedComment": "Try this at home - no equipment needed.",
    "posterTagline": { "primaryLine": "30s HIIT", "secondaryLine": "Jumping Jacks" }
  },
  "copyPacks": {
    "ig_reels": { "hook": "...", "caption": "...", "hashtags": "...", "cta": "...", "pinnedComment": "...", "posterTagline": { "primaryLine": "30s HIIT", "secondaryLine": "Jumping Jacks" } },
    "tiktok": { "hook": "...", "caption": "...", "hashtags": "...", "cta": "...", "pinnedComment": "...", "posterTagline": { "primaryLine": "30s HIIT", "secondaryLine": "Jumping Jacks" } }
  },
  "variants": [
    {
      "aspectRatio": "9:16",
      "duration": 30000,
      "downloadLink": "https://hhcontent.s3.../drop_9x16_30.mp4?X-Amz-...",
      "posterLink": "https://hhcontent.s3.../poster.png?X-Amz-...",
      "posterLinksByChannel": { "tiktok": "https://hhcontent.s3.../poster_tiktok.png?X-Amz-..." },
      "size": 1234567
    }
  ],
  "createdAt": "2025-09-15T10:30:00Z",
  "updatedAt": "2025-09-16T14:20:00Z"
}
```

### Export Response Structure
```json
{
  "videoDownloadLink": "https://hhcontent.s3.../drop_9x16_30.mp4?X-Amz-...",
  "coverLink": "https://hhcontent.s3.../cover.png?X-Amz-...",
  "ctaUrl": "https://mybrand.com/fitness",
  "copyPack": {
    "hook": "Get fit in 30 seconds!",
    "caption": "30 seconds of Jumping Jacks to get your heart pumping. Perfect for a quick warm-up or standalone cardio burst.",
    "hashtags": "#fitness #workout #hiit",
    "cta": "Link in bio",
    "pinnedComment": "Try this at home!",
    "posterTagline": { "primaryLine": "30s HIIT", "secondaryLine": "Jumping Jacks" }
  }
}
```
Note: `ctaUrl` is optional (present when team has social CTA configured).

---

## Webhooks & build-your-own retention

Completions, program progress and daily signals, pushed to your server as they happen. No polling. Works for every surface: your own player, the pre-built embed, Hyperhuman Club, the member web app and HyperCast.

### Events

| Type | Fires when | Use it for |
| --- | --- | --- |
| `workout_session.completed` | A session closes: `sessions/complete`, `sessions/end`, feedback on an open session, or HyperCast **done** | Leaderboards, streaks, "workout done" nudges, CRM sync |
| `plan.started` | `plans/start` enrolls a member (new enrollment, earlier plan re-activated, or `restart: true`) | Onboarding journeys, challenge sign-up |
| `plan.progress_updated` | A session fills a program slot, or a member skips a slot | Progress bars, "behind this week" nudges |
| `plan.completed` | That change finishes the program (every slot completed or skipped) | Certificates, rewards, next-program upsell |
| `insights.digest_ready` | The member's daily digest for today is built (once per member-local day) | Streak-at-risk and personal-best nudges, weekly recaps |

Not sent: a session that `sessions/start` closes because a newer one started; unfinished slots that drop at week end (`dropMissedAtWeekEnd`) — the next `plan.progress_updated` snapshot counts them. `insights.digest_ready` fires the first time today's digest is built: the morning push for app members, otherwise the first `GET .../insights/digest` of the day.

### Payload

```json
{
  "id": "evt_6721f0c4a1b2c3d4e5f60718",
  "type": "workout_session.completed",
  "apiVersion": "2026-11",
  "occurredAt": "2026-10-29T18:42:11.000Z",
  "organizationId": "63415bf67a9c7000126616c1",
  "endUser": { "endUserId": "6650aa000000000000000000", "externalUserId": "crm-12345" },
  "data": {
    "sessionId": "6721bb000000000000000000",
    "workoutId": "66a1dd000000000000000000",
    "workoutName": "Upper Body Power",
    "startedAt": "2026-10-29T18:10:02.000Z",
    "endedAt": "2026-10-29T18:42:11.000Z",
    "progressSeconds": 1890,
    "completionPercent": 100,
    "endReason": "complete",
    "surface": "embed",
    "plan": { "planId": "66b2cc000000000000000000", "positionInPlan": 7 }
  }
}
```

- `id` is the dedupe key (also the `webhook-id` header). `endUser` is `null` for anonymous sessions.
- Events go to the organization the activity belongs to: your key's organization, the `organizationId` sent with the session, or the member's only organization. A member of several organizations with no organization on the call produces no event.
- `workout_session.completed`: `endReason` is `complete` (100%) or `end` (kept last progress; HyperCast **done** sends the progress the controller reported). `surface` is `api`, `embed`, `club`, `member_web` or `cast`. `plan` is `null` unless the session filled a slot of the active program (progress `>= 0.1`). Most leaderboards count `completionPercent >= 80`.
- `plan.*`: `planId`, `planName`, `progress` (`completedWorkouts`, `skippedWorkouts`, `totalWorkouts`, `completionPercentage`, `currentWeekNumber`, `durationInWeeks`, `workoutsPerWeek`). `plan.progress_updated` adds `change` (`kind`: `slot_completed` | `slot_skipped`, `positionInPlan`, `sessionId`). `progress` is a full snapshot: keep the newest `occurredAt`.
- `insights.digest_ready`: `date`, `timeZone`, `variant`, `week` (`completed`, `planned`, `status`, `daysRemaining`, `weeklyStreakWeeks`) and `highlights[]` (`kind`, `copyKey`, `priority`, `celebration`). Key your own copy on `copyKey`, or fetch the digest (10x) for text.

**Privacy:** ids only, never email or name. No health values, no cycle data. Digest highlights are training signals only (streaks, records, milestones, program, pacing); sleep, heart, body, nutrition and pain signals stay in the digest. A removed end user stops producing events.

### Verify every request

Signed with [Standard Webhooks](https://www.standardwebhooks.com/). Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature` (`v1,<base64 HMAC-SHA256 of id.timestamp.body>`, key = your `whsec_` secret). Use the official library and the raw body:

```js
// npm i standardwebhooks
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.HYPERHUMAN_WEBHOOK_SECRET); // whsec_...

app.post('/hyperhuman/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString('utf8'), req.headers); // checks signature + 5 min timestamp window
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(200); // answer first, work after
  if (await seen(event.id)) return; // at-least-once: dedupe on id
  await handle(event);
});
```

```python
# pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["HYPERHUMAN_WEBHOOK_SECRET"])
event = wh.verify(request.get_data(as_text=True), dict(request.headers))
```

### Delivery

- **At least once**, **no order guarantee**. Dedupe on `id`; apply snapshots by newest `occurredAt`.
- **10 s** to answer with a 2xx. Redirects are not followed (3xx is a failure).
- **Retries** after 5 min, 30 min, 2 h, 5 h, 10 h, then every 12 h, for up to 3 days.
- **Failing endpoint:** when retries run out after about 3 days with no successful delivery, it is turned off and team managers get an email. A `410 Gone` turns it off at once. Turn it back on with `PATCH ... { "status": "enabled" }`.
- **HTTPS only**, public addresses only (private, loopback and link-local are refused at save and at send).
- **Quota:** each delivery attempt counts **1x** on your Content API limits. When a limit is used up, deliveries wait for the reset. Nothing is dropped.

### Manage

Settings → Integrations → **Webhooks** in Hyperhuman Teams (managers), or the API (tag `endusers-webhooks`, all **1x**):

| Method | Path | Does |
| --- | --- | --- |
| `POST` | `/v1/orgs/{organizationId}/webhooks` | Create: `url`, `events[]` (or `["*"]`), optional `description`. Returns `secret` once |
| `GET` | `/v1/orgs/{organizationId}/webhooks` | List endpoints |
| `GET` | `/v1/orgs/{organizationId}/webhooks/{webhookId}` | One endpoint, status, last delivery |
| `PATCH` | `/v1/orgs/{organizationId}/webhooks/{webhookId}` | Change `url`, `events`, `description`, `status`; `rotateSecret: true` returns a new secret (old one stops at once) |
| `DELETE` | `/v1/orgs/{organizationId}/webhooks/{webhookId}` | Remove |
| `POST` | `/v1/orgs/{organizationId}/webhooks/{webhookId}/test` | Send a signed sample `type` now; returns your status code |
| `GET` | `/v1/orgs/{organizationId}/webhooks/{webhookId}/deliveries` | Attempts, last status, next retry |
| `GET` | `/v1/orgs/{organizationId}/events` | Catch-up feed: every event of the last 30 days, oldest first. Filters `type`, `endUserId`, `since` |

10 endpoints per organization. Unknown event types return `400`. Events are recorded from the moment your organization has its first endpoint.

### Recipe: a 4-week streak challenge

Leaderboards, streaks and nudges, your rules, your brand, on your data.

1. **Enroll:** `POST .../endusers/invite` with `externalUUID`, build a 4-week, 3-per-week program in Teams, then `POST .../endusers/{endUserId}/plans/start`.
2. **Capture completions:** subscribe to `workout_session.completed` (every surface). Count `completionPercent >= 80`.
3. **Streak:** consecutive weeks with at least 3 completions in the member's time zone, or read `week.weeklyStreakWeeks` from `insights.digest_ready`.
4. **Leaderboard:** completions per member in the challenge window, shown with your own display names (opt-in only).
5. **Nudges:** `insights.digest_ready` highlights with `copyKey` `streak_at_risk.*` → your push.
6. **Finish line:** `plan.completed`.

```js
// Weekly streak: consecutive weeks (Monday start, UTC) with >= target completions, counting back from this week.
function weeklyStreak(completedAtIsoList, target = 3, now = new Date()) {
  const weekStart = (d) => { const x = new Date(d); x.setUTCHours(0, 0, 0, 0); x.setUTCDate(x.getUTCDate() - ((x.getUTCDay() + 6) % 7)); return x.getTime(); };
  const perWeek = new Map();
  for (const iso of completedAtIsoList) perWeek.set(weekStart(iso), (perWeek.get(weekStart(iso)) || 0) + 1);
  let week = weekStart(now);
  if ((perWeek.get(week) || 0) < target) week -= 7 * 864e5; // this week still in progress
  let streak = 0;
  while ((perWeek.get(week) || 0) >= target) { streak += 1; week -= 7 * 864e5; }
  return streak;
}
```

---

## 13) Troubleshooting

### Common Issues & Solutions

#### Rate Limiting Issues

**Problem:** Getting 429 "Too Many Requests" errors
```json
{ "error": { "code": "TooManyRequests", "message": "Hourly rate limit exceeded" } }
```

**Solutions:**
- Honor `Retry-After` (seconds) when present, then `X-RateLimit-Reset`
- Check `X-RateLimit-Remaining` before making further requests
- On a **trial** key, caps are 1/100 of the paid plan (e.g. Platform trial = 25/hour)
- Do not retry stock-exercises on a plan-gated 403 — it does not consume quota, and retrying will not unlock the SKU
- Cache metadata responses (they change infrequently)
- Prefer recommend over generate; generate/chat writes are 10x
- Consider upgrading (or waiting for trial to convert) for higher limits

**10x multiplier:** workout/plan generate, recommend, adapt; insights; chat writes; video generation; full video export; nutrition photo-log analysis; **stock-exercises**. Weight is consumed on admitted 400/403. **Exception:** a plan-gated stock-catalog **403** does not consume. Generate/recommend/adapt success is **200**, not 201.

#### End-user plan progress looks wrong

**Problem:** `GET .../plans/active/progress` shows `completed: false` while the app UI lists completed workout sessions.

**Common causes:**

1. **Repeated `POST .../plans/start`** — each call with `restart: true`, or legacy behavior before idempotent start (2026-06), moved `startDate` forward so earlier sessions fall outside the progress window. **Fix:** call start once at enrollment; poll progress only. Use `restart: true` only for an intentional program reset. `POST .../plans/assign` does not enroll and will not create an active plan or progress window.
2. **Sessions before `startDate`** — check `GET .../plans/active` for `startDate` vs session `updatedAt`.
3. **Same workout completed twice** — only one plan position is credited when the workout appears once in the plan order.

**Problem:** Progress briefly showed `completed: true` then reverted.

**Cause:** A `plans/start` call (or `restart: true`) ran after the workout finished, resetting the progress window. Expected server behavior, not stale cache.

#### Authentication Problems

**Problem:** 401 "Invalid API key" or 403 "Forbidden"
```json
{ "error": { "code": "Unauthorized", "message": "Unauthorized" } }
```

**Solutions:**
- Verify API key is correctly set in `X-Api-Key` header
- Ensure API key belongs to the organization in the request URL
- Check for extra whitespace or encoding issues
- Confirm API key hasn't expired
- For user-specific endpoints, ensure user authentication

**Problem:** Steady stream of `401 "API key is not authorized"`, then `429` after a while.

**Cause:** The key is **locked or disabled** (revoked/suspended by an admin), and the client is retrying it in a loop. The `401` is permanent until an admin re-enables the key; after repeated attempts in a short window the server escalates to `429` (`"Too many requests with an unauthorized API key"`) with a `Retry-After` header to shed the load. **Fix:** stop retrying, surface a "key disabled" state to the operator, and resume only once the key is re-enabled.

#### Signed media URL fails in a native player

**Problem:** The same `/playlist` or `/music` URL opens in Safari (or desktop Chrome) but AVPlayer / `video_player` / `just_audio` fails with “no permission”, a secure-connection error, or a generic load failure. Signatures are still inside the 7-day window.

**Cause:** The player did not request the URL **exactly** as returned. S3 SigV4 is query-string auth. Rebuilding the URL, re-encoding `%2F`, or dropping `x-amz-*` params yields **403**. Native stacks often surface that as a permission or TLS error, not as S3 XML. `X-Api-Key` on the **file** request is unnecessary (it is for Content API routes only) and can break browser `fetch` CORS; it is not required for AVPlayer.

**Solutions:**

- Pass the JSON `url` string into the player unchanged. Do not `Uri.parse` + rebuild `queryParameters`.
- Do **not** send `X-Api-Key` on the media request.
- Confirm a **GET** on the same string (browser, or `curl -s -o /dev/null -w '%{http_code}' '<url>'`) returns **200** or **206**. Do not use `curl -I` — that is HEAD; these URLs are signed for GET only.
- Workout intro, exercise clips, and music use the same host and signing rules — do not look for a second URL variant.
- Re-fetch the playlist / music / video-asset if the URL may have been cached past expiry.

#### Workout/Plan Not Found

**Problem:** 404 errors when requesting workouts or plans
```json
{ "error": { "code": "NotFound", "message": "Not Found" } }
```

**Solutions:**
- Verify the id is a **persisted** catalog / assigned / recommend id — not generate JSON and not a locally minted hex string
- Workout generate/adapt returns ephemeral JSON with **no `id`**. Play via the JSON-receiver iframe + `GET /v1/video-asset/{exerciseId}?locale=`. Omitted `locale` is `en-US` instruction and cue audio, one language. `GET /v1/workouts/{id}/playlist` on that body → **404**
- Empty org catalog is **200** `{ data: [], links: { total: 0 } }`, not 404. Add workouts in Teams first
- There is **no** `GET /v1/workouts` list, **no** `GET .../library`, **no** `GET /v1/categories`. Use `GET /v1/orgs/{orgId}/workouts` and `GET /v1/workouts/metadata`
- `GET /v1/workouts/categories` is parsed as `GET /v1/workouts/{workoutId}` with id `categories` → **400**, not metadata
- Check if content belongs to your organization and is published

#### Unknown field on generate / adapt

**Problem:** `400` `ValidationError` — `property userGuidance should not exist` (or `userRequirements`)

**Cause:** Generate and adapt use **different** free-text names. Extra keys are rejected.

**Solutions:**
- Generate (`POST .../workouts/generate`, `POST .../plans/generate`): `userRequirements`
- Adapt (`POST .../workouts/{id}/adapt`, `POST .../plans/{id}/adapt`): `userGuidance`
- Do not send both. Do not invent aliases.
- `methodologyProfileId` is generate-only (team profile id from `GET .../methodologies`). Sending it on adapt / recommend → **400**.

#### Programming DNA not applied / empty Inspector

**Problem:** You sent `methodologyProfileId` and generate returned **400**, or you omitted it and `methodology` is `null` (`inspection.complianceStatus` is often `not_applicable`)

**Cause:** Unknown, personal (member-only), or inactive ids fail closed (**400**). On organization API keys, omit still means none — this API never auto-applies `isTeamDefault`. Hyperhuman Club / member web apply the team default only when coaches have **Set as team guideline** and **Make default**.

**Solutions:**
- List first: `GET /v1/orgs/{orgId}/methodologies`. Use an `id` from that `items` array.
- Empty `{ items: [] }` means no published team profiles (**Set as team guideline** then **Make default** in Hyperhuman Teams) — not an outage. Up to **10** team profiles can be listed.
- Omit the field when you want no methodology on this API. Do not send a guessed id.
- For Club / member web house methodology: publish a team guideline and make it the default. A personal Style is not enough.
- Persist `inspection` if you need an audit trail. There is **no** `GET /v1/generations/{id}`. Later `GET /v1/plans/{id}` does not replay the snapshot. Inspector is generate-only on this API.

#### Generation Timeouts

**Problem:** Workout/plan generate or adapt appears to fail after ~30 seconds

A client that closes the socket first never sees the JSON. The server can still finish **`200`**. That abort is **not** a `500` and is **not** retry-safe.

**Solutions:**
- Set the HTTP client timeout to at least **120 seconds** (same ceiling as the public edge)
- Transient upstream model errors (rate limiting, temporary unavailability) are retried **server-side** with backoff before the API answers. Under provider load that adds several seconds to generate / adapt / chat / insights calls; it is part of the 120 s budget, not a reason to retry from the client
- Do not immediately retry the same generate after an abort — you may already have a completed response you never read
- Complex requests take longer; prefer `POST .../workouts/recommend` when a library match is enough
- Reduce complexity (narrower duration, fewer collection filters) if you must stay under a shorter client budget

#### Localization Issues

**Problem:** Content not localized despite providing locale parameter
```json
// Still returns English content
{
  "name": "Push-ups",
  "description": "Upper body exercise"
}
```

**Solutions:**
- Verify locale code format (e.g., `fr-FR`, not `fr` or `xx-XX` — unsupported locales return `400` on generate endpoints)
- Check supported locales list (18 languages available)
- Fallback behavior: requested → base language → English
- Some content may not be translated yet (nested workout `nameLocalized` falls back to English when DB has no translation)
- **AI generate endpoints** (`POST .../workouts/generate`, `POST .../plans/generate`): legacy `name` / `description` fields stay English base; use `nameTranslations` / `descriptionTranslations` and `*Localized` fields for localized values. On `GET /v1/plans/{id}?locale=...`, top-level `name`/`description` are already resolved — only when generate returned a persisted `id` (`endUserProfileDetails.endUserId` was set).

#### Clip shorter than playlist `duration`

**Problem:** `exercise.main.video` file is ~27s but playlist `exercise.duration` is 60s (or similar mismatch).

**This is intended** for `kind: exercise`. `duration` is the planned work window; the MP4 is a short looping demo. Honor `main.video.loop: -1` (`playsInLoop` default true) and keep looping until the segment clock elapses. Auto-advance on `duration`, not on video `ended`. `class` / `multiExercise` / `promo` / `educational` `duration` should match the file. A mismatch against `recordingStartFrame`/`recordingEndFrame` (extract-context) would be a split bug — not this case.

#### Session Tracking Problems

**Problem:** Session duration shows as 0 or inaccurate
```json
{
  "data": {
    "duration": 0,
    "progress": 0
  }
}
```

**Solutions:**
- Always send `progressSeconds` in PATCH requests
- PATCH every 10-30 seconds during active playback
- Send final PATCH before ending session
- Duration capped at 2x expected workout length

#### Export URL Issues

**Problem:** Video/audio export URLs return 404 or don't work
```json
{ "error": { "code": "NotFound", "message": "Not Found" } }
```

**Solutions:**
- Full video is not created on every workout save. It exists after Generate in Teams, or after a save by a coach who has **Full workout video** on (Workout Defaults → Format, off by default). Turning it on does not render workouts already saved. With it off, a studio edit removes every locale and `export/video/stream_url` stays `404`. With it on, only the coach's language is re-rendered and that locale's previous ready video is served until the new one finishes. A failed render leaves the previous file. Other locales stay until a later save with the switch off, or a Generate for that locale. A `200` during a re-render is the previous file. Re-fetch the route; a URL already fetched stays valid until it expires. A first render often takes tens of minutes, plus queue time. Poll on the order of minutes. There is no Content API to start a render or read status.
- Narrative audio (`export/audio/stream_url`) is a separate file. A studio edit does not remove it. `404` means that narration is missing for the locale rules on that route.
- Hold the URL in memory for the playback session and re-fetch `stream_url` before the ~7-day expiry. Do not log it or write it to durable storage — the query string is the credential.
- If you poll `stream_url` for readiness after a coach triggers a render, start only after the trigger, back off from ~5 s to 30 s, and stop on the first `200`. A `404` means "no ready export", not a transient error — do not retry it hot.
- `GET .../playlist` still plays when the full video is missing

#### User Management Errors

**Problem:** Batch user invites fail partially or completely
```json
{
  "successful": 85,
  "failed": [
    {"email": "user@example.com", "reason": "User already exists"},
    {"email": "invalid-email", "reason": "Invalid email format"}
  ]
}
```

**Solutions:**
- Validate email formats before sending
- Check for existing users first
- Handle partial failures gracefully
- Respect rate limits (100 users per batch, delays between batches)

#### Network & Connectivity Issues

**Problem:** Intermittent connection failures or timeouts

**Solutions:**
- Implement retry logic with exponential backoff
- Handle network errors differently from API errors
- Use circuit breaker pattern for reliability
- Cache responses when possible
- Implement offline queuing for critical operations

### Debugging Tools

#### API Response Headers
```javascript
// Check all response headers for debugging
const response = await fetch(url, options);
console.log('Status:', response.status);
console.log('Headers:', Object.fromEntries(response.headers.entries()));

// Rate limiting info
console.log('Rate Limit Remaining:', response.headers.get('X-RateLimit-Remaining'));
console.log('Rate Limit Reset:', new Date(response.headers.get('X-RateLimit-Reset') * 1000));
```

#### Request Logging
```javascript
// Log all API requests for debugging
class ApiDebugger {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async request(url, options = {}) {
    const startTime = Date.now();
    console.log(`🚀 API Request: ${options.method || 'GET'} ${url}`);

    try {
      const response = await fetch(url, {
        ...options,
        headers: {
          'X-Api-Key': this.apiKey,
          ...options.headers
        }
      });

      const duration = Date.now() - startTime;
      console.log(`✅ Response: ${response.status} (${duration}ms)`);

      if (!response.ok) {
        const error = await response.json();
        console.error('❌ API Error:', error);
        throw new Error(`API Error: ${response.status} - ${error.message}`);
      }

      return response.json();
    } catch (error) {
      console.error('💥 Request Failed:', error.message);
      throw error;
    }
  }
}
```

#### Common Error Patterns
```javascript
// Handle different error types appropriately
async function safeApiCall(url, options) {
  try {
    const response = await fetch(url, options);

    // Network errors
    if (!response) {
      throw new Error('Network connection failed');
    }

    // HTTP errors
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));

      switch (response.status) {
        case 401:
          throw new Error('Authentication failed - check API key');
        case 403:
          throw new Error('Access denied - check organization permissions');
        case 404:
          throw new Error('Resource not found - check IDs');
        case 429:
          throw new Error('Rate limited - implement backoff');
        case 422:
          throw new Error(`Validation error: ${errorData.message}`);
        default:
          throw new Error(`API error: ${response.status} - ${errorData.message}`);
      }
    }

    return response.json();
  } catch (error) {
    // Log for debugging
    console.error('API Call Failed:', {
      url,
      error: error.message,
      timestamp: new Date().toISOString()
    });
    throw error;
  }
}
```

### Performance Optimization

#### Caching Strategies
```javascript
class ApiCache {
  constructor(ttlMinutes = 60) {
    this.cache = new Map();
    this.ttl = ttlMinutes * 60 * 1000; // Convert to milliseconds
  }

  get(key) {
    const item = this.cache.get(key);
    if (!item) return null;

    if (Date.now() - item.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }

    return item.data;
  }

  set(key, data) {
    this.cache.set(key, {
      data,
      timestamp: Date.now()
    });
  }

  // Cache metadata responses (rarely change)
  async getMetadata(endpoint) {
    const cacheKey = `metadata:${endpoint}`;
    let data = this.get(cacheKey);

    if (!data) {
      data = await apiCall(endpoint);
      this.set(cacheKey, data);
    }

    return data;
  }
}
```

#### Request Batching
```javascript
// Batch multiple metadata requests
async function batchMetadataRequests(endpoints) {
  const results = {};

  for (const endpoint of endpoints) {
    try {
      results[endpoint] = await apiCall(endpoint);
      // Small delay to respect rate limits
      await new Promise(resolve => setTimeout(resolve, 100));
    } catch (error) {
      console.error(`Failed to fetch ${endpoint}:`, error);
      results[endpoint] = null;
    }
  }

  return results;
}

// Usage
const metadata = await batchMetadataRequests([
  '/v1/workouts/metadata',
  '/v1/workouts/equipment/metadata',
  '/v1/plans/metadata'
]);
```

---

### Change Log
- **v91 (2026-10-30):** **Webhooks.** Five signed events push completions, program progress and daily signals as they happen: `workout_session.completed`, `plan.started`, `plan.progress_updated`, `plan.completed`, `insights.digest_ready`. Standard Webhooks signing (`webhook-id`, `webhook-timestamp`, `webhook-signature`), at-least-once, retries for 3 days, auto-off plus manager email after 3 days of failures. Manage under tag `endusers-webhooks` (`/v1/orgs/{organizationId}/webhooks`, 1x) or Teams Settings → Integrations → Webhooks. Catch-up feed `GET /v1/orgs/{organizationId}/events` (30 days). Each delivery attempt counts 1x. Recipe: a 4-week streak challenge. Additive — **no client change required**.
- **v90 (2026-10-29):** **Hydration and SpO2 in insights.** New signal kind `hydration` on the nutrition pillar (`copyKey` `hydration.up` / `hydration.down`): 7-day water intake vs the member's 28-day usual, never a fixed target. The recovery pillar adds optional `spo2AvgPct` (last night's average SpO2) and the `strain` radar can list `SpO2 -N pts` in `detail`. The nutrition pillar adds optional `waterMlToday` and `avgWaterMl7d` (mL). Pushed `steps` entries now feed the movement pillar and `steps` signals, and pushed `hydration` entries feed the water fields and `hydration` signal (send one daily total per day; the latest `recordedAt` wins). `oxygenation` pushes are stored but do not feed insights. Additive fields only — **no client change required**.
- **v89 (2026-10-27):** **Finished plans and skipped copies.** `schedule.programFinished` is `true` once every slot is completed or skipped. A session without `programPosition` fills the earliest open copy of its workout, and a skipped copy only when no open copy is left. By-id workout and program reads (detail, `similar`, `playlist`, `music`, `video-assets`, exports, plan workouts) and plan start accept your organization's content and Hyperhuman template content; another organization's id is `404`.
- **v88 (2026-10-27):** **Current week stays due.** With `dropMissedAtWeekEnd` on, unfinished slots drop only in fully elapsed weeks before `schedule.currentWeekNumber`. That week stays due after the plan length (`daysSinceStart >= durationInWeeks * 7`). A later week still drops once it has elapsed. `todaysWorkout` is omitted when today already has a completion or no open slot remains. Absent `todaysWorkout` on an incomplete plan is not program complete. Synced overview, AGENTS, llms-index.
- **v87 (2026-10-23):** **Hosted player slot.** Pre-built `HYPERHUMAN_EMBED_INIT` accepts optional `programPosition` (not an iframe query param). An attributed embed continues `GET .../sessions/recent` instead of calling `sessions/start` while a session is open, so start cannot close that session and fill a second plan slot. `programPosition` stays off PATCH. A session closed by a later `sessions/start` keeps its own `programPosition` and pins that slot when it still matches. Custom-player `start()` calls `sessions/recent` first and does not PATCH a lower `progressSeconds` than the stored duration. The hosted player completes a finish that arrives during that lookup, shows in-player feedback after `sessions/complete`, and still starts playback at the first segment. Synced overview, AGENTS, llms-index, custom-player guide.
- **v86 (2026-10-23):** **Pin an active-program completion to the slot the member opened.** Optional `programPosition` on `POST .../sessions/start`, `POST .../sessions/end`, and `POST .../sessions/complete` fills that `positionInPlan` when the workout id there is still this workout. Omitted, the earliest open matching slot is filled (a skipped copy only when no open copy is left). A completion of a skipped slot clears `skipped` for the slot that was filled. Skip accepts any open slot, including one after the due slot (`404` when there is no active plan). `sessions/start` still closes older in-progress sessions for that workout; those keep their own progress and do not take the new `programPosition`. `sessions/end` below progress `0.1` does not fill a slot. Synced overview, AGENTS, llms-index, custom-player guide.
- **v85 (2026-10-23):** **Skip an open program workout.** `dropMissedAtWeekEnd` on `POST .../plans/start` and `PATCH .../plans/active/settings` (omitted on start means on; `false` keeps missed workouts due). `POST .../plans/active/slots/{position}/skip` marks one open slot skipped and does not write a completion (`201`, no body; `400` if the position is not in the plan; `409` if the flag is off or the slot is already completed or skipped). `GET .../plans/active` returns the flag. Progress items add `skipped` and optional `skippedAt`. Restart clears stored skips.
- **v84 (2026-10-13):** **Sessions, feedback, plan access, plan workouts, error content type.** `sessions/start` on an unknown workout is **404** and creates no session. Patch, end, and complete are **404** when the session is not for that workout and trainee, checked before a completed **409**. `progressSeconds` must be `>= 0`. Feedback `ratingId` / `difficultyId` must come from the matching options list (a swap is **400**). Plan start / end / settings / active return `id`, `planId`, `endUserId` (deprecated `_id` / `workoutCollectionId` / `traineeId` aliases remain); active adds `plan` and `schedule` and no longer returns the raw collection document. `GET /v1/plans/{id}/workouts` adds `positionInPlan` and `links.total` (seconds). Progress and plan-generate nested workout `duration` stay milliseconds. `export/.../stream_url` errors are JSON `application/json`. Workout generate preflight rejects an unsupported `locale`. Plan generate with `endUserId` does not apply a personal Style; pool / methodology / locale failures stay **400**. Group list items include `organization`. Synced overview, AGENTS, llms-index, custom-player guide.
- **v83 (2026-10-13):** **Video-asset `locale` default.** `GET /v1/video-asset/{id}` with an omitted or blank `locale` returns `en-US` instruction and cue audio, one language, same as the exercise lists in v82. It does not return every locale. Pass `locale` for another language. Generate and adapt JSON still have no audio: play them through the JSON receiver and put that same `locale` on the iframe URL (the player forwards it; a missing query is `en-US` there too). Playlist omit, export-video omit, and export-audio omit are unchanged. Detail names on this GET stay the stored strings. In source; not yet in the production image as of this reading. Corrected the library bullets that still said an omitted list `locale` returns every language (v82 already changed that).
- **v82 (2026-10-12):** **List `locale` default.** Omitting `locale` on exercise lists (`stock-exercises`, `video-assets`, and the shared list shape) uses **`en-US`** instruction audio and names. It does not return every locale. Pass another BCP-47 `locale` for that language. Name search with no `locale` matches `name` and `nameTranslations.en-US`. A high-risk program generate whose workout pool fails the safety floor returns **400** (no template program). Not yet in the production image as of the 12 Oct reading.
- **v81 (2026-10-09):** **End-user identifier examples (no contract change).** The `endUserId` examples in Swagger and in this sheet are a 24-character hex id instead of an email, and the insights, health-data and nutrition cURL and JS examples use `$ENDUSER` (the `endUserId` returned by `GET .../endusers/find`). An email or an `externalUUID` still resolves, but an email in the path ends up in access logs, so prefer the hex id. `find` returns no `endUserId` for a user without an app profile; use the `externalUUID` or the email for those.
- **v80 (2026-10-02):** **Signed-URL hygiene, stock page cost, export polling, AI retry latency.** Signed media URLs are bearer credentials: do not log, persist, or share them; key client caches by resource id (the API may return the same URL string for the same asset for about an hour, which lets HTTP caches reuse bytes). Stock catalog pages without `locale` are ~2 MB / several seconds for 50 rows — always send `locale`. `export/.../stream_url` readiness polling: start after the trigger, back off 5 s → 30 s, stop on `200`, `404` is "not ready". Transient upstream model errors are retried server-side with backoff (adds seconds under load; inside the 120 s budget). AGENTS.md is now partner copy only (the former maintainer section moved out of the served docs; implementation names and file paths removed from all guides). Synced overview, AGENTS, llms-index.
- **v79 (2026-10-02):** **Wrong routes and stock `locale`.** Documented 404s for `GET /v1/workouts` and `GET /v1/orgs/{orgId}/programs`, and that the key-authenticated public surface has no sign-in / token / account routes (only paths in `/openapi.json` exist). Public-copy pass: partner text references the public contract only — no member-app route mirrors. Stock catalog callers should send `locale` so instruction audio is not every locale.
- **v78 (2026-09-17):** **Signed media URLs — play verbatim.** Playlist / video-asset / music / export `url` values are SigV4 query-signed S3 GETs. Play the string unchanged; do not send `X-Api-Key` on the media request; do not parse/rebuild the query (S3 then **403**). Range allowed. Same contract for workout intro, exercise clips, and music — no alternate host. New cheat-sheet subsection + troubleshooting. Synced overview, AGENTS, llms-index, custom-player guide, `VideoLinks`/`AudioLinks`, playlist/music/export Swagger.
- **v77 (2026-09-16):** **`inspection.generationId` optional.** Organization-key generate may omit `generationId` when there is no user owner (dummy API user). Store the rest of `inspection` yourself — still no `GET /v1/generations/{id}`. Synced overview, AGENTS, llms-index.
- **v76 (2026-09-11):** **Programming DNA / Generation Inspector — Club vs this API.** Partner generate stays opt-in (`methodologyProfileId`; omit = none). Hyperhuman Club / member web apply the published **team default** (Set as team guideline + Make default); personal Style never reaches members. Inspector stays `methodology` / `inspection` on generate only. No public `defaultMethodologyProfileId`. Synced overview, AGENTS, llms-index.
- **v75 (2026-09-11):** **Programming DNA partner-doc pass.** Dedicated llms-index section; Teams publish action is **Set as team guideline**; list cap **10** team profiles; generate DTO Swagger matches fail-closed **400** (supersedes leftover “ids are ignored”). Preflight stays a 1x feasibility dry-run; Inspector stays `methodology` / `inspection` on generate only. Synced overview, AGENTS, methodologies Swagger.
- **v74 (2026-09-11):** **`methodologyProfileId` fail-closed.** Unknown, personal, or inactive ids return **400** (same message; omit still means none). Synced overview, llms-index, AGENTS, and generate Swagger. v72 “ignored — check `methodology`” is superseded.
- **v73 (2026-09-11):** **Generation Inspector on generate.** Partner copy names the generate snapshot **Generation Inspector** (`methodology` + `inspection`: `hardRulesApplied`, `violations`, `complianceStatus`, `generationId`). Store it yourself — no `GET /v1/generations/{id}`, and later `GET /v1/plans/{id}` does not replay it. Workout preflight stays a **1x** feasibility dry-run (not the Inspector). Plan generate has no preflight. Empty methodologies `{ items: [] }` means no published team profiles (not a kill switch). Synced overview, AGENTS, and llms-index. Public surface still `methodology` / `inspection` only.
- **v72 (2026-09-11):** **Programming DNA wording.** Content API stays opt-in (`methodologyProfileId`; unknown / personal ids ignored — check `methodology`). Do not invent a Teams or Content API kill switch. In Hyperhuman Teams the Programming tab is always on; it does not auto-apply on Club / Content API generate. Partner routes unchanged from v71.
- **v71 (2026-09-11):** **Programming DNA on generate.** `GET /v1/orgs/{orgId}/methodologies` lists team profiles (summary + `ruleSummary`, no full guidance text). Optional `methodologyProfileId` on workout/plan generate (opt-in; omit = none applied). Workout `POST .../workouts/generate/preflight` is a **1x** dry-run. Generate responses may include `methodology` and `inspection`. Recommend / adapt / chat do not apply Team DNA. No `GET /v1/generations/{id}`. Synced overview, AGENTS, llms-index, and generate Swagger.
- **v70 (2026-09-07):** **Assign / enroll / unassign.** Org unassign of the active plan **deletes** the access row. `.../plans/end` only clears `isActive`. Synced AGENTS + llms-index + overview.
- **v69 (2026-09-04):** **AI User Insights — additive digest contract + push.** Digest gains optional `dataProfile` (`wearable` / `workout_only`, drives `dataCompleteness` weighting), `variant` (`daily` / `week_ahead` / `week_review`), `readiness` (band, intensity, message, contributors), `week` (completed / planned / status / daysRemaining / weeklyStreakWeeks), `shareText`, `locale`, `timeZone`; pillars gain optional `trend7d` and `why` on every pillar; signals gain optional `copyKey`. New signal kinds (`weekly_streak`, `strength_pr`, `load`, `completion_depth`, `program_milestone`, `variety`, `progression`, `onboarding`, `rest_day`, `form`, `plan_adapted`, `sleep`, `sleep_regularity`, `rhr_trend`, `hrv_trend`, `strain`, `steps`, `weight_trend`, `week_ahead`, `week_review`) — kinds are additive, render unknown kinds generically. Deterministic labels shortened (`5-day streak on the line. Train today.`). Day boundaries and the digest cache follow the end user's profile time zone (else UTC). Daily insights push now leads with personal bests / strength PRs, milestones, streak savers, week secured, recovery checks, pace and the Sunday review (else `todaysFocus`), lands in a local 06:00-10:59 window when the time zone is known, and carries `data.kind`. Endpoint paths, params and required fields unchanged — **no client change required**.
- **v68 (2026-09-02):** **Public docs: partner stock catalog only.** Cheat sheet / AGENTS / llms-index describe `GET .../stock-exercises` (gated, 10x, in OpenAPI). No other catalog surface is documented here.
- **v67 (2026-09-01):** Partner playback remains `GET .../stock-exercises` (gated, 10x, in OpenAPI). Synced AGENTS maintainer note.
- **v66 (2026-08-31):** **Copy-paste contract pass.** Quick Start / API Map / Flow B / JS snippets now match live generate: `--max-time 120` / `AbortSignal.timeout(120000)`, ephemeral body (not `{ workout }`), `userRequirements` vs `userGuidance`. List snippet is `GET /v1/orgs/{orgId}/workouts` (unscoped `GET /v1/workouts` is **404**). Troubleshooting covers minted-id playlist 404s, invented `/library` `/categories` paths, and the generate/adapt field-name 400. Trial checklist items 7–10. Circuit-breaker `timeout` labeled as recovery wait, not HTTP. Synced overview latency band, AGENTS, and llms-index.
- **v65 (2026-08-31):** **Generate vs adapt field names + client timeout.** Generate free-text is `userRequirements`; adapt is `userGuidance` (the other name → `400` `property … should not exist`). Keep the HTTP client open ≥ **120s** on generate/adapt; a client abort can still finish `200` — do not immediately retry. Do not invent `GET .../library` or `GET /v1/workouts/categories` — catalog is `GET .../workouts`; categories are `GET /v1/workouts/metadata`. Synced generate/adapt Swagger, overview, AGENTS, and llms-index.
- **v64 (2026-08-31):** **Workspace catalog vs pre-made stock.** `GET .../workouts` and `GET .../plans` are this org’s published library only — empty **200** until items are added in Teams (including Add from stock). No public stock-workout/program list or clone. `stock-exercises` remains clips (Ultra yearly / custom). Synced list/recommend Swagger, overview, AGENTS, and llms-index.
- **v63 (2026-08-31):** **AI User Insights — push vs digest.** `activity_logged` / `activity_volume` are digest and Health HQ Patterns signals only. The daily insights push still leads with `personal_best`, `milestone`, `streak_at_risk`, `recovery_debt`, or `pace` (else `todaysFocus`). No client change required.
- **v62 (2026-08-31):** **AI User Insights — copy + cache truth.** Deterministic signal labels are punchier (`No {weekday} workouts in 3 weeks`, `N low-recovery days - keep today light`, nutrition CTA `Snap one meal today`). Headlines/actions are one fact / one verb-led action (160-char headline cap is a ceiling). Digests are cached for the calendar day; `forceRefresh` regenerates. `[CTA]` may appear as both `action` and `insights[]` — render the action once. Expanded `insights` text should wrap, not slice. `source: "ai"` is soft phrasing only. Nutrition CTA weekday cap stays server-side (Mon/Thu).
- **v61 (2026-08-31):** **AI workout generate source-pool fail-closed.** `POST .../workouts/generate` with `exerciseSources` that do not include `free_stock` and yield 0 matching clips returns **400** (create-similar copy: team-only vs selected-sources). The server does not silently query free stock or swap in a template workout. Omit/empty `exerciseSources` still defaults to free+premium. Collection floor ≥ 10 unchanged and still wins when include/exclude is set. Synced AGENTS, llms-index, overview Workflow 3, generate Swagger.
- **v60 (2026-08-29):** **Hosted embed usage.** Pre-built and JSON-receiver iframes send the URL / `HYPERHUMAN_EMBED_INIT` key as `X-Api-Key`. Those GETs and pre-built sessions are ordinary **1x** Content API calls (`GET /video-asset/{id}` included). Host generate remains **10x**. The JSON receiver waits for a non-empty key before media fetches. No separate embed-play unit. Synced overview, AGENTS, llms-index, and custom-player guide.
- **v59 (2026-08-29):** **Public-copy hygiene.** Partner body and changelog use public contract language only: OpenAPI field names, match-any / AND filters, and the team app for operator social workflows. No repo files, vendor or model names, member-app route mirrors, operator telemetry, or unnamed internal specs.
- **v58 (2026-08-29):** **Cheat sheet is a served public doc.** Linked from Swagger overview / Scalar Resources at `/docs/guides/content-API-cheat-sheet.md`. Removed maintainer-only notes, non-public hostnames, unpublished-route lists, and paywall/internal-admin details.
- **v57 (2026-08-29):** **Public docs drop internal markdown links.** Partner-facing copy no longer points at repo-only specs. Cross-links stay on AGENTS, overview, the custom-player guide, and OpenAPI.
- **v56 (2026-08-29):** **Catalog access is fail-closed.** Ultra is allowed only when Stripe status is `active` and the interval is year. Ultra monthly is denied. Custom/Enterprise needs the sold `stockVideoCatalogApi` flag on an active team plan. Content with no API access is **401** (not a catalog 403). Plan-gated **403** still does not consume. Partner copy stays production-only.
- **v55 (2026-08-29):** **Ultra monthly stock 403 no longer consumes 10x.** Ultra monthly catalog 403 does not consume quota. Unknown interval on active Ultra still consumes so yearly is never skipped.
- **v54 (2026-08-29):** **Live-contract accuracy.** AI recommend/generate/adapt succeed with **HTTP 200** (not 201). Trial checklist + §11 403 row + troubleshooting 10x note include the stock plan-gate consume exception. Health-data **push** is 200 (per-entry results); nutrition **log** is 201. Partner copy stays production-only (no internal environment hosts or internal usage-stats links).
- **v53 (2026-08-29):** **Stock catalog plan-gated 403 no longer consumes 10x.** Plan-gated catalog 403 (Platform/trial/Content, flag off) does not consume quota. Ultra monthly consume leftover closed in v55. Synced overview, AGENTS, and llms-index.
- **v52 (2026-08-29):** **Doc-accuracy pass after trial-key integration failures (no runtime change).** Documented trial caps (1/100 of paid hourly/daily while Stripe `trialing`); removed the fake 1000/100/500 rate-limit table and `X-RateLimit-Limit: 1000` samples; added `GET .../stock-exercises` to every 10x list and noted that admitted **400/403 still consume weight**; Swagger Try-it for groups `limit` now defaults to **10** (max 50) with a **400** example; chat list requires `organizationId` + `userEmail`/`userExternalUUID` (hex example, not `default`); end-user list schema `maximum: 100`; find is a single-record lookup (no `limit`); error samples use the live `{ error: { code, message } }` envelope; 429 guidance honors `Retry-After` first (RFC 9110). Added a **Trial / first-integration checklist** in §7. Synced overview, AGENTS, and llms-index.
- **v51 (2026-08-26):** Clarified that organization `/stats` returns admitted **weighted quota units**, not raw request counts or rejected/aborted outcomes. Per-endpoint outcome breakdown is not on this public surface.
- **v50 (2026-08-25):** **`GET /v1/orgs/{orgId}/stats` scope.** Documented current hour/day + caps only (no per-endpoint breakdown). Requests without `X-Api-Key` do not increment the org API-client counters.
- **v48 (2026-08-21):** **Clip duration vs playlist `duration` (not a bug).** For `kind: exercise`, playlist `duration` is the planned work window (time-based slot or `reps × estimatedRepDuration`); the MP4 is a short looping demo (often ~3–40s). `main.video.loop: -1` when `playsInLoop` is true (default) — keep looping until the clock elapses (e.g. 27s file in a 60s slot). `class` / `multiExercise` / `promo` / `educational` `duration` equals file length. Synced playlist/video-asset/generate Swagger, AGENTS, overview, llms-index, and custom-player guide.
- **v47 (2026-08-18):** **Workout and program tags.** Optional freeform `tags` (max 5) on workouts and plans. List/detail responses include `tags[]`. Org library lists accept CSV `tags` (match any, case-insensitive exact) and `q` also ranks on tag strings. Tags are org labels for findability — not categories, not goals, and not recommend/generate pool filters.
- **v46 (2026-08-13):** **End-user library contract accuracy.** Documented assign vs start vs end vs unassign: start does not require a prior assign; assign 400 is all-or-nothing catalog eligibility; unassign is org-owned and idempotent (missing / other-org ids ignored, not 400) so retired org content can still be removed; `plans/end` deactivates, unassign deletes. GET library lists are this-org only; a leftover `visibility` query on that list is unused. Assign retries are safe (already-assigned + unique membership).
- **v46 (2026-08-13):** **Plan adapt — end-user generated programs.** `POST .../plans/{planId}/adapt` accepts member-owned `end-user-ai-generated` plans (from `POST .../plans/generate`; fetch via `GET /v1/plans/{id}`, not the org catalog list). Other-org / non-member plans stay **404**. Fresh generated plans adapt **in place**; catalog-derived copies (`originalProgramId`) that are not active → **400** (start first → next-slot adapt). Swagger 400/403/404 + Try-it `generated-plan` example aligned.
- **v45 (2026-08-13):** **End-user content library (assign / unassign).** New tag `endusers-content`: `GET .../endusers/{endUserId}/workouts` + `POST .../workouts/assign` + `POST .../workouts/unassign`, and the same three paths under `.../plans`. Org-scoped library (published public/private workouts; trainer-owned public/private plans). Assign never sets `isActive` — `POST .../plans/start` remains enrollment. Batch max 10 workouts / 5 plans; any ineligible id → **400** all-or-nothing. Empty **200**. Notifications on new rows only (`sendAssignmentEmail` / `sendAssignmentPush`, default true). Unassign is idempotent (missing or non-org ids ignored); unassigning the active plan ends enrollment. Team API key + active member (404 otherwise). Rate limit 1x.
- **v44 (2026-08-11):** **Library rotation + pool filters.** Optional `categoryIds` on `POST .../workouts/recommend` and `POST .../plans/generate` (hex only). Recommend = published+public; generate = public workouts matching any listed category. Empty / &lt;3 category-filtered generate pool → **`400 BadRequest`** (propagates; no template fallback). Docs: Plan = Program; Public/Private (no Unlisted); categories ≠ sequence; no workout tags; retire-without-delete + ordered Programs recipes.
- **v43 (2026-07-29):** **Media delivery doc-accuracy pass.** Playlist / preview / video-asset / export media are **S3-presigned progressive MP4** (audio M4A), typically ~7-day expiry — **not** HLS, `master.m3u8`, `stream.hyperhuman.cc`, `hls.js`, or ABR. Corrected OpenAPI `VideoLinks`/`AudioLinks` + controller examples (SigV4 `X-Amz-*`), overview, AGENTS, llms-index, custom-player guide, STOCK catalog sample, and this cheat sheet. Follow-up: instruction-audio `assetUri` examples also use `X-Amz-*` (no `?token=`). Runtime delivery unchanged; docs now match live presigned URLs.
- **v42 (2026-07-29):** **AI workout generate — `includeExerciseCollections`.** Content API `POST /v1/orgs/{orgId}/workouts/generate` now accepts `includeExerciseCollections` (allowlist) alongside existing `excludeExerciseCollections` (denylist), matching Autopilot generate. Mutually exclusive; compose with `exerciseSources`; pool floor ≥ 10. Prefer include for same trainer/location — do not simulate include by excluding every other collection. Swagger Try-it example + cheat sheet use-case table. Service contract unchanged (pass-through). Adaptation Content API collection fields still deferred.
- **v41 (2026-07-27):** **Embedded player — portrait / immersive / BGM pause.** Synced overview, AGENTS, llms-index, custom-player-guide, §8 embeds: iframe `allow` includes `accelerometer; gyroscope`; full-viewport (`100dvh`) mobile snippet; `HYPERHUMAN_HOST_IMMERSIVE` + `HYPERHUMAN_ORIENTATION_TOGGLE`; music-off = `pause`+`muted` (iOS ignores `volume`). Aligns with the hosted player chrome.
- **v40 (2026-07-23):** **Embedded player — E2E accuracy pass.** Documented JSON-receiver must be an **iframe**; ready ≈1s; missing `organizationId` inline error; generate JSON passed as-is; media = `GET /video-asset/{id}` (instruction clips may be empty; invalid key → 401); `sessionMode` on JSON INIT ignored. Workflow 3 / Flow B / Flow E / §8 / llms embed section / AGENTS pitfalls updated. Endpoint table adds `GET /video-asset/{id}`. Swagger video-asset + sessions/start Related notes. Client-facing docs list public host `team.hyperhuman.cc` only.
- **v39 (2026-07-23):** **Post-trim accuracy + marketing samples.** Audio export locale docs corrected (trainer preferred locale + family fallback; not video-export rules). Org plans list notes `end-user-ai-generated` exclusion. Restored video-assets **kinds** product→kind guide; stock filters no longer claim `kinds` parity. Trimmed plan workouts + feedback/metrics get/list ops. Marketing samples aligned to live recommend/generate/adapt/insights shapes.
- **v38 (2026-07-23):** **Embedded player docs vs Teams player.** Corrected false “both embeds track sessions” claim — session/feedback/`HYPERHUMAN_SESSION_EVENT` are **pre-built only**; JSON-receiver uses `GET /v1/video-asset/{exerciseId}` and has no sessions. Documented pre-built query gaps (`brandLogoUrl`, URL `sessionMode`/`endUserId`/`externalUserId`, `keyHandshake` precedence), real `CAST_STATE` shape. Feedback Swagger examples use real option hex ids (fake `rating-5-stars` → `400`). Synced overview, AGENTS, and llms-index.
- **v37 (2026-07-23):** **Swagger trim + offset decision.** Trimmed long Swagger operation copy (social-publish, playlist/music, sessions/feedback, video-assets, stock-exercises, org plans list, plan details/metadata, measured metrics, org metadata, exports). Filled empty social-publish ops. Plan generate Try-it example for ephemeral (no `endUserId`) vs persisted. **`offset` stays hidden in OpenAPI** — document follow `links.next` (runtime still accepts `offset`). Synced AGENTS, overview, llms-index, cheat sheet.
- **v36 (2026-07-23):** **MD sync from live API audit learnings.** Extended cheat sheet + AGENTS + overview + llms-index + custom-player guide: plan generate persistence examples (`endUserId` vs no-id); plan adapt required `endUserProfileId`; Recommend vs Generate playback row; org metadata AI-flag caveat; AI HTTP **200** vs create **201**; recommend body whitelist (no top-level `durationOptions`); localization persistence note tied to plan `id`; JS `generatePlan` comment.
- **v35 (2026-07-23):** **Plan generate/adapt contract truth (live-verified).** Plan generate top-level `id` only when `endUserProfileDetails.endUserId` is set; without it no plan id. Plan adapt `endUserProfileId` required (omit → 400); Swagger examples updated. Source field names: generate = `workoutCollectionSources`, adapt = `workoutSources`. Org metadata `ai*Enabled` documented as app visibility flags, not Content API gates. Synced overview, AGENTS, llms-index, and Swagger.
- **v34 (2026-07-23):** **Generate Swagger trim + Flow B accuracy.** Workout/plan `generate` Swagger copy reduced to recommend/adapt style (kept ephemeral JSON / store plan `id` / sources / locale / 10x). Cheat-sheet Flow B no longer claims `generate → workoutId → /playlist` / sessions; session tracking lives on Flow A (persisted ids); generate = JSON-receiver / custom player only.
- **v33 (2026-07-23):** **Adapt playback/persistence clarity.** Overview Workflow 4 + workout/plan adapt Swagger: workout adapt is ephemeral JSON (no top-level workout id; same JSON-receiver path as generate); plan adapt returns a persisted adapted program `id` to store. Trimmed adapt Swagger copy to org-workouts outcome-led style.
- **v32 (2026-07-23):** **Doc-accuracy pass (live Content API + Teams embed player).** Fixed false Generate→`/playlist`/`/music` workflow; documented ephemeral workout-generate JSON vs persisted plan ids; clarified Recommend vs Generate (quiz→library = Recommend); JSON-receiver has **no PulseMix** (`mixBgMusic`/`videoAudioMode` on posted JSON ignored); feedback options wire shape `{ data: { rating, difficulty } }`; recommend/generate/adapt succeed with HTTP **200**; removed invented recommend match-score weights; pagination defaults: org workouts/plans omit→**20**, video-assets/groups omit→**10**; custom-player guide playlist = segment-by-kind `data[]` (not `instances`). Synced overview, AGENTS, llms-index, custom-player-guide, Swagger ops (recommend/music/feedback/stats/generate storage note).
- **v31 (2026-07-19):** **Stock exercise catalog — Ultra (annual) OR custom entitlement.** `GET .../stock-exercises` allows access for active Stripe Ultra yearly teams as well as custom/Enterprise plans with `stockVideoCatalogApi`. Ultra monthly stays denied. Team-scoped gate. Yearly Ultra is treated as catalog-entitled at runtime.
- **v30 (2026-06-30):** **AI generation localization — doc sync with Swagger + live validation.** Aligned cheat sheet, AGENTS.md, llms-index.md, and overview.md with the shipped `locale` body param on `POST .../workouts/generate` and `POST .../plans/generate`: compact localization flow table, minimal fr-FR request examples, live-validated response examples (workout: `Renforcement Musculaire Intermédiaire`; plan: `Forme Générale Intermédiaire 4 Semaines`), invalid-locale `400` curl sample, and integrator note that generate vs `GET /v1/plans/{id}?locale=...` resolve top-level fields differently. Swagger localization sections and `localized-fr-FR` Try-it-out examples updated.
- **v29 (2026-06-30):** **AI program generation localization.** Documented implemented `locale` support on `POST /v1/orgs/{orgId}/plans/generate`: optional BCP-47 body `locale` (validated → `400` when unsupported); additive response fields `nameTranslations`, `descriptionTranslations`, `difficultyLabel`, nested `nameLocalized` / `descriptionLocalized`; AI name/description authored in the existing structure call; translation maps **persisted** on the saved plan so `GET /v1/plans/{id}?locale=...` is localized end-to-end. Updated plan generation response example (fr-FR), field glossary, localization "What Gets Localized" list, and JS client `generatePlan()` helper.
- **v28 (2026-06-30):** **Stock exercise catalog API (custom / Enterprise plans).** New `GET /v1/orgs/{orgId}/stock-exercises` (tag `library-stock-exercises`) exposes the **browsable stock catalog** - canonical **published, public, `single-exercise`** clips, both **free and premium**. Gated behind the new `stockVideoCatalogApi` entitlement **plus** `contentApi` with positive API caps and a non-locked key (otherwise `403`); enabled only on team-scoped custom/Enterprise entitlements (off unless sold). Filters mirror `video-assets` (`q` substring, `equipmentIds`, `muscleGroupIds`, `skillLevels`, `executionSides`, `coach`, `collectionNames`) plus `availability` (`free-stock`/`premium-stock`/`all-stock`, default `all-stock`); sort `date`/`name`/`kind`; pagination `offset`/`limit` (default 10, max 50). Each call counts as **10x**. Response reuses the `video-assets` envelope but omits `trainer`, `bundle`, and `organization` entirely (stock is platform content, not workspace uploads). This is a browse/playback surface and is **not** AI-generation fuel (generate/adapt keep their own stock sourcing).
- **v27 (2026-06-15):** **Locked/disabled API key back-pressure (no breaking change).** Documented that a **locked** or auth-**disabled** key returns `401` with `message: "API key is not authorized"` (permanent until an admin re-enables it — do **not** retry), and that a client retrying such a key in a tight loop is now throttled to **`429`** (`"Too many requests with an unauthorized API key"`) with a **`Retry-After`** header after repeated unauthorized attempts in a short window (currently ~10/~60s per key), instead of endlessly returning `401`. Added a new "Locked or Disabled API Keys" subsection under Security & Rate Limiting, a `429` example + locked-key `401` example in Errors & Rate Limiting, and a troubleshooting entry for "stream of 401s then 429".
- **v26 (2026-06-14):** **Doc-accuracy pass against live API (no contract change).** Documented two endpoint families that are part of the public Content API but were previously undocumented in this cheat sheet: **Content Autopilot** (`/v1/orgs/{orgId}/content-autopilot` — scheduled generate/clone/publish automations with activate/deactivate/run-now and execution events) and **AI Chat** (`/v1/chat/conversations` — conversational coach; `organizationId` is a body/query field and pagination is `page`-based). Corrected the **workout/plan recommend** request docs: the body is strictly whitelisted — workout recommend accepts only `endUserProfileDetails` + `locale` (no top-level `durationOptions`/`categoryId`/`goalIds`/`equipment`/`limit`), plan recommend additionally accepts a root `goalIds`; results are always capped at 12. Clarified that `GET /v1/workouts/{id}/sessions/recent` is end-user (bearer) scoped, returns the most recent **non-completed** session, and returns `404` for API-key-only calls. Documented that `GET .../endusers/find` can return `endUserId` (the internal id for `{endUserId}` paths) and that `externalUUID`/`endUserId` are omitted when absent.
- **v25 (2026-06-14):** **Error-behavior clarifications (no contract change).** Documented that **AI adapt** endpoints (`POST .../workouts/{workoutId}/adapt`, `POST .../plans/{planId}/adapt`) require the target `{workoutId}` / `{planId}` to belong to `{orgId}` — content from another organization returns **`404 Not Found`**. Clarified end-user identifier resolution: an `{endUserId}` (email / external id / internal id) that does not resolve to an end user in the organization returns **`404`** (an unknown identifier never produces a `500`), and a malformed `email` on `GET .../endusers/find` returns **`400 ValidationError`**. `GET /v1/workouts/{id}` returns details reliably even when an underlying exercise reference is incomplete. Replaced a real-looking sample id in **User Identifier Formats** with a synthetic example.
- **v24 (2026-06-13):** **AI User Insights — logged wearable activities feed the `movement` pillar (additive).** Discrete wearable sessions (runs, rides, swims) are now stored structurally and summarized into the digest: the `movement` pillar grounds its headline/action in real sessions (most-recent session, weekly active minutes, per-type breakdown, 90-day distance/pace bests) when available, otherwise falls back to the wearable activity score. Two new deterministic signal kinds homed on `movement`: **`activity_logged`** (celebration when an activity was logged today) and **`activity_volume`** (weekly session count + active minutes). The connect-wearable CTA is also suppressed when logged activities exist. Movement pillar response field contract is unchanged.
- **v23 (2026-06-12):** **Nutrition photo logging (vision AI food tracking).** New two-step photo food-logging flow on the org surface: `POST .../endusers/{endUserId}/nutrition/photo-log` (multipart meal photo + optional `date`/`hint` → vision AI estimate with per-item breakdown, persisted as a 1-hour single-use pending log; counts as **10x** API calls) and `POST .../nutrition/photo-log/{pendingLogId}/confirm` (optional `totals` override; writes the same day totals, nutrition score, digest, and nutrition pillar as direct log and chat logging; 1x). Photos are processed in memory and discarded; the structured estimate (description + per-item breakdown) is persisted on the nutrition entry and surfaces as `description` in the nutrition pillar's `nutritionMeals[]`.
- **v22 (2026-06-12):** **AI User Insights — detected signals (additive).** Digest gains **`highlights[]`** (max 8, priority-sorted) and pillars gain **`insights[]`** (max 3): deterministic signals (`personal_best`, `milestone`, `streak_at_risk`, `pace`, `recovery_debt`, `adherence_trend`, `day_of_week_pattern`, `time_of_day_pattern`, `correlation`, `plateau`, `cta`) plus up to 2 AI-proposed `ai_pattern` entries (`source: "ai"`). **`body` pillar is now conditional** (only with wearable body score or recent weight entries). Headlines no longer carry pillar-name prefixes; cross-pillar repetition (readiness, streaks) is deduped server-side. Personal bests / milestones surface only on the day they occur; `streak_at_risk` + CTAs only on current-day digests; nutrition CTA capped to Mon/Thu. Data-correctness fixes: deltas vs **prior-7-day** baseline (omitted without today's data or baseline), workout streak over **90 days**, pacing on **calendar-week** completions anchored to the digest date.
- **v21 (2026-06-06):** **Library search alignment** — documented fuzzy **`q`** on **`GET /v1/orgs/{orgId}/workouts`** and **`GET /v1/orgs/{orgId}/plans`**: fetch-all → relevance rank → paginate; **`sort` ignored while `q` is set**; **`links.total`** reflects post-search count; **`locale`** scopes translation matching. Clarified **`video-assets`** **`q`** remains **substring** (sort still applies). New [Library search behavior](#library-search-behavior) section.
- **v20 (2026-06-02):** **Workout playlist audio transcripts** — `GET /v1/workouts/{id}/playlist` enriches all `AudioLinks` objects with additive **`type`**, **`locale`**, and **`scriptText`** (empty string when no script). Single-exercise **`main.instructionAudio[]`** always reports `type: exerciseInstruction`. Backward compatible (`url` unchanged).
- **v19 (2026-05-22):** **Class video chapters** on workout playlist — class segments now emit `{ "class": { "kind": "class", ... } }` (replacing the legacy `{ "multiExercise": { "kind": "multi-exercise", ... } }` shape for class slots). Optional **`class.chapters[]`** when AI chapter analysis has been run: `name`, `startTimeSec`, `endTimeSec`, `equipment[]`, `muscleGroups[]` (timestamps relative to `main.video`; chapter text is an analysis snapshot and is not re-localized by `?locale=`). Swagger `ClassSegment` / `ClassVideoChapter` schemas and playlist response example updated; cheat sheet + AGENTS + custom-player-guide aligned.
- **v18 (2026-04-30):** **`audioInstructions[].scriptText`** (and **`audios[].scriptText`** on the per-asset detail) now exposed on `GET /v1/orgs/{organizationId}/video-assets` and `GET /v1/video-asset/{id}` (when listed in OpenAPI) — verbatim narrator transcript for closed captions, accessibility overlays, search, and AI summarization. Additive; omitted on recordings without a stored script — clients must null-check.
- **v17 (2026-04-30):** **Swagger UI fix for `GET /v1/orgs/{organizationId}/video-assets`.** Try-it-out hung because CSV filter examples were documented as arrays. Public wire contract unchanged (still CSV strings). Swagger now shows `type: String` + CSV examples with allowed values in the description.
- **v16 (2026-04-30):** **video-assets** list gains **filtering and sorting**. New query params: **`q`** (case-insensitive substring on `name`; locale-aware when `?locale=` is set), **`equipmentIds`** / **`muscleGroupIds`** (CSV of 24-hex ids; match any), **`kinds`** / **`skillLevels`** / **`executionSides`** (CSV of allowed values), **`coach`** (single value), **`collectionNames`** (CSV of exact strings). New **`sort`** param accepts `+`/`-` prefix with allowlist `date` (alias of `createdAt`) / `name` / `kind`; default `-date`. All filters AND-combine and run before pagination so `links.total` stays exact. Unknown enum values, malformed hex ids, and unsupported sort fields return `400 ValidationError`.
- **v15 (2026-04-30):** **video-assets** list now exposes **`audioInstructions[]`** for `kind: single-exercise` rows (type `exerciseInstruction`; locale-narrowed when `?locale=` is supplied, otherwise all available locales). Other kinds return `[]`.
- **v14 (2026-04-30):** **video-assets** workspace-only contract enforced — **`visibility` query removed**; list is workspace-recorded public assets only (stock / pay-as-you-go stay hidden); `locale` now actually localizes `name`, `muscleGroups[].name`, `equipment[].name`; cheat sheet duplicate "Smart Library" reference subsection removed.
- **v13 (2026-04-30):** **video-assets** docs shortened (outcome-led Swagger; **instruction vs original audio** for **`single-exercise`** / **`multi-exercise`**; **visibility** demoted — workspace defaults **public**); cheat sheet + `llms-index` + AGENTS aligned.
- **v12 (2026-04-30):** **Organization Smart Library** — documented **`GET /v1/orgs/{orgId}/video-assets`** (workspace members, `trainer` as creator reference, **`kind`** vs product video-type guide including Original ↔ **`multi-exercise`** / **`playsInLoop`**); Quick Navigation + API Map + flows + examples + dedicated section before Exercise Groups; pagination default **`limit` 10**.
- **v11 (2026-04-26):** Doc sync block: **`offset`/`limit`** for org workout and plan lists (removed incorrect `?page=`); documented **default `limit` 10** when omitted on those routes; **`limit` max 50**; end-user list **max 100**; **plan `.../workouts`** response uses **`{ data, links }`** (no `page` pagination object); export stream = **`text/plain`** presigned URL; social publish prose without vendor-specific route names; `find` user = email or **external id** (not “UUID”); cross-links [AGENTS.md](https://content.api.hyperhuman.cc/AGENTS.md).
- **v10 (2026-03-08):** Added Exercise Groups (Micro Workouts) documentation: Flow H, curl examples, Organization Groups Filtering, dedicated section with list/detail/exercises response structures; added GID variable.
- **v9 (2026-03-08):** Added Social Drops endpoints (list, get, export); list drops with filtering (sourceType, aspectRatio, duration, search); export channel bundle (video, cover, copy pack) for Reels, Shorts, TikTok.
- **v8 (2026-01-17):** Added comprehensive filtering documentation for organization workouts/plans; clarified plan workouts pagination (returns all workouts); added complete error response structures; expanded request examples with all optional fields; documented excluded endpoints; clarified user management offset-based pagination; added complete query parameter documentation.
- **v7 (2026-01-16):** Added AI Adaptation endpoints (workouts/plans adapt); added Organization endpoints (metadata, join-options, stats); added workout music endpoint; emphasized progressSeconds as primary duration tracking method; expanded AI Insights with new coaching signals; added AI Adaptation workflow; fixed non-existent endpoint references; added comprehensive AI method selection guide with request examples; CRITICAL FIX: Added missing video/audio export endpoints to API map and complete reference; added export request examples and integration guides; added exports vs playlists decision framework; fixed navigation consistency.
- **v6 (2026-01-10):** Enhanced AI Insights with coaching signals: readiness bands, risk detection, form cues, program pacing, why explanations, alternative actions.
- **v5 (2026-01-10):** Added AI User Insights endpoints (daily digest, pillar detail); supports 6 pillar types with status indicators.
- **v4 (2025-01-03):** Updated session tracking: `progressSeconds` is now the primary duration tracking method; session status is `started`/`completed` (not `active`/`paused`/`completed`); added session response structure documentation; improved session lifecycle examples.
- **v3 (2025-01-15):** Added recommendations vs generation, user management, enhanced security & rate limiting, comprehensive endpoint reference, corrected session lifecycle and feedback formats.  
- **v2 (2025-10-01):** Added flows, filters, session lifecycle, embeds, security & rate-limit notes.  
- **v1:** Initial cheat sheet.
