# Content APIs (Updated)

The Content API serves the KinesteX catalog: **exercises**, **workouts**, and multi-week **plans**. Every endpoint is a plain REST `GET`, authenticated with your company API key, paginated, filterable, and localized.

Use it to build your own content browser, a workout picker, a plan catalog, or to feed content into your own AI agent.

**Base URL:** `https://data.kinestex.com`

> If KinesteX provisioned a dedicated domain for your company (a white-labeled host or a custom CDN), swap the host only. Paths, parameters, and payloads are identical.

**Endpoints:**

| Endpoint | Returns |
|----------|---------|
| `GET /api/exercises/client` | Paginated exercise list |
| `GET /api/exercises/client/{id}` | One exercise, by numeric ID or title |
| `GET /api/workouts/client` | Paginated workout list |
| `GET /api/workouts/client/{id}` | One workout with its full exercise sequence |
| `GET /api/plans/client` | Paginated plan list |
| `GET /api/plans/client/{id}` | One plan with weeks, days, and the current workout |

**Quick start:**

```bash
curl "https://data.kinestex.com/api/exercises/client?body_parts=Chest&difficulty_level=easy&limit=5" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Language: en"
```

**What changed since the legacy Content API** ([still documented here](/docs/content-api)):

- **Server-side filtering on every list.** `search`, `body_parts`, `categories`, `difficulty_level`, `level`, and `translation_languages` are applied in SQL, so you no longer fetch everything and filter on the client.
- **Real pagination.** Offset paging with a complete `pagination` block (`total`, `total_pages`, `has_next`) instead of opaque cursor tokens.
- **Localization you can plan around.** One language parameter for the whole response, plus `translation_languages` so you know which languages an item actually has.
- **One host.** Content, the [AI Trainer API](/docs/trainer-api), and workout sessions all live on `data.kinestex.com`.

Read [Getting Started](/docs/content-api-v2/content-api-v2-getting-started) first (auth and language apply to every endpoint), then [Filtering & Parameters](/docs/content-api-v2/content-api-v2-filtering) for rules that hold across all three catalogs.

## Getting Started

Everything on this page applies to all six endpoints.

**Authentication**

Send one of the two credentials. If an `Authorization` header is present it wins and the API key is ignored.

| Header | Use when |
|--------|----------|
| `x-api-key: YOUR_API_KEY` | Server to server, or SDK calls made on behalf of your company |
| `Authorization: Bearer YOUR_JWT` | A signed-in end user (see [obtaining a JWT](/docs/trainer-api/trainer-api-auth)) |
| `x-user-id: END_USER_ID` | Optional, alongside `x-api-key`, to resolve a specific end user |

`x-user-id` only matters for plan progress: with it, `GET /api/plans/client/{id}` returns that user's completion flags. For pure catalog reads you can leave it out.

> Keep your company API key on your server. A client app should send a **user JWT** instead. These endpoints do not accept a raw session ID.

Two ways to get that JWT, both starting on your backend:

- Call [verify-api-key](/docs/trainer-api/trainer-api-auth) with your API key and the user's ID, then hand the returned `token` to the client.
- Mint a short-lived session with `POST /api/sessions`, ship the session ID to the client, and have the client exchange it for a JWT at `POST /api/auth/session-verify` with an `x-session-id` header. The `token` it returns expires with the session, so the client can never outlive it. Full request and response shapes are in [Session tokens](/docs/trainer-api/trainer-api-auth).

Either way the client then sends `Authorization: Bearer <token>` on these requests.

**Language**

One value controls the language of every title, description, tip, and step in the response. Resolution order, highest first:

1. `?lang=es` query parameter
2. `Language: es` header
3. `Accept-Language: es-MX,es;q=0.9` header (the first entry wins)
4. English

All sources are normalized to the ISO 639-1 base code, so `es`, `ES`, `es-MX`, and `pt_BR` resolve the way you expect in whichever form you send them. `en-GB` and `EN` both resolve to `en`.

Where `?lang=` is honored:

| Endpoint | `?lang=` | Missing translation falls back to English |
|----------|-----------|-------------------------------------------|
| `GET /api/exercises/client` | Yes | Yes |
| `GET /api/exercises/client/{id}` | Yes | Yes |
| `GET /api/workouts/client` | Yes | No, see below |
| `GET /api/workouts/client/{id}` | Yes | Yes |
| `GET /api/plans/client` | Yes | Yes |
| `GET /api/plans/client/{id}` | No, headers only | Yes |

Two exceptions worth coding around:

- **The single-plan endpoint ignores the lang parameter.** `GET /api/plans/client/{id}` reads the `Language` or `Accept-Language` header only.
- **The workout list does not fall back to English.** If you request `?lang=es` and a workout has no Spanish row, that workout's `translation` is `null` while `translation_languages` still lists what exists. Handle the null, narrow the list with `translation_languages=es`, or pass `include_exercises=true` (that path does fall back to English).

> **Recommended:** send the `Language` header on every request **and** `?lang=` on list requests. The header covers the endpoints that only read headers; the parameter makes the language explicit in the cache key and in your logs.

**Supported language codes**

Any ISO 639-1 code is accepted; what you get back is whatever translations exist. The platform languages KinesteX ships today are listed in [Language & Localization](/docs/customization-parameters/language-localization), and more can be added on request.

Coverage is **per item**, not global. Read `translation_languages` on a list item to see what that item actually has, or filter the list with `translation_languages=<code>` to return only items that have it. An unknown or untranslated code is never an error: you get English (or, on the workout list, a `null` `translation`).

**Media URLs**

Image, video, and audio URLs are returned as Firebase Storage download URLs (`https://firebasestorage.googleapis.com/v0/b/.../o/<path>?alt=media`). Treat them as opaque: fetch them as-is, and do not rebuild or parse the path. They are stable for the lifetime of the asset but the host and encoding are an implementation detail.

## Filtering & Parameters

These rules hold on all three list endpoints, so you only learn them once.

**Which filter each catalog accepts**

| Filter | Exercises | Workouts | Plans |
|--------|-----------|----------|-------|
| `search` | Yes | Yes | Yes |
| `body_parts` | Yes | Yes | Yes |
| `categories` | Yes | No | No |
| `category` | No | Yes (single value) | No |
| `difficulty_level` (alias `dif_level`) | Yes | No | No |
| `dif_level` | Yes (alias) | Yes (single value) | No |
| `level` | No | No | Yes (integer) |
| `translation_languages` | Yes | Yes | Yes |
| `remove_inactive` | Yes | No | No |
| `include_exercises` | No | Yes | No |
| `include_weeks` | No | No | Yes |
| `include_shared_library` | Yes | Yes | Yes |
| `lang` | Yes | Yes | Yes |
| `limit` / `offset` | Yes | Yes | Yes |

**Array parameters: three interchangeable forms**

```text
?body_parts=Chest,Triceps                 // comma-separated
?body_parts=Chest&body_parts=Triceps      // repeated
?body_parts[]=Chest&body_parts[]=Triceps  // bracketed
```

Surrounding whitespace is trimmed, so `?body_parts=Chest, Triceps` works. Multi-word values stay intact: `?body_parts=Lower Back,Full Body` is two body parts, not four (URL-encode the space as `%20`).

`body_parts`, `categories`, `difficulty_level`, and `translation_languages` are arrays. `category` and `dif_level` **on the workout list are single values**: a comma in them is matched literally, so `?dif_level=easy,medium` looks for a difficulty named "easy,medium" and returns an empty page. Issue one request per value and merge client-side.

**Combining filters**

- **Within one filter, values are OR.** `categories=Strength,Cardio/Endurance` returns exercises in either category.
- **Across filters it is AND.** `body_parts=Chest&difficulty_level=easy` returns easy chest exercises.
- **There is no "match all" mode.** `body_parts=Chest,Triceps` means chest *or* triceps, never both.
- **Every name filter is case-insensitive.** `easy`, `Easy`, and `EASY` are the same request.

**Values that match nothing**

Name filters compare on string equality, so an unrecognized value is not an error. You get `200` with an empty array and `pagination.total: 0`. The one exception is `?level=` on the plan list, which must be an integer and returns `400` otherwise.

> **Empty value means no filter.** Sending `?body_parts=` or `?categories=` applies *no* filter and returns the full unfiltered page. If your UI builds query strings from optional fields, omit the parameter entirely rather than sending it blank.

**Booleans must be literal**

`include_shared_library`, `remove_inactive`, `include_exercises`, `include_weeks`, and `include_audio` accept `true` / `false` (also `1` / `0`, `t` / `f`). Anything else, such as `yes` or `on`, is read as **false** without an error. `include_shared_library=yes` silently hides the entire shared library.

**Search is matched against English titles**

`search` is a case-insensitive partial match on the **English** title, whatever `lang` you send. `?search=flexiones&lang=es` returns nothing; search for `push` and read the Spanish titles off the result.

**Result ordering**

Newest first by creation date. When `search` is present, results are ranked by match quality first (exact title, then prefix, then partial) and creation date second.

**Allowed values**

Body parts (18), shared by all three catalogs:

```text
Neck            Shoulders   Chest       External Oblique
Abs             Biceps      Triceps     Forearms
Traps           Lats        Lower Back  Glutes
Quads           Hamstrings  Abductors   Adductors
Calves          Full Body
```

Exercise categories (8):

```text
Strength        Muscle Gain          Weight Loss   Cardio/Endurance
General Fitness Wellness/Flexibility Warm Up       Cooldown
```

Exercise difficulty (3): `easy`, `medium`, `hard`. Returned lowercase on exercises, accepted in any casing everywhere.

Workout `category` is free text set per workout rather than a fixed list (values in use include `Fitness`, `Cardio`, `Strength`, `HIIT`). Workout `dif_level` is stored capitalized (`Easy`, `Medium`, `Hard`) and returned as stored, so compare case-insensitively on your side. Read an unfiltered first page to build a picker rather than hard-coding either list.

**The same filters work on the JWT routes.** `/api/exercises`, `/api/workouts`, and `/api/plans` accept every parameter listed here and return the same data plus internal fields. The one difference: they spell the library toggle `include_kinestex_library` instead of `include_shared_library`.

## Exercises

An exercise is a single movement: demo video, difficulty, target body parts, coaching tips, and the AI model that scores the user's form.

---

**List exercises**

```text
GET /api/exercises/client
```

| Parameter | Type | Default | Matches |
|-----------|------|---------|---------|
| `search` | string | none | Partial, case-insensitive match on the English title |
| `body_parts` | string[] | none | Body part name, case-insensitive |
| `categories` | string[] | none | Category name, case-insensitive. Deleted categories never match |
| `difficulty_level` | string[] | none | `easy`, `medium`, `hard`. Alias: `dif_level`. If both are sent, `difficulty_level` wins |
| `translation_languages` | string[] | none | Only exercises that have a translation in one of these language codes |
| `remove_inactive` | bool | `true` | `false` also returns deactivated exercises |
| `include_shared_library` | bool | `true` | `false` returns only your company's own exercises |
| `lang` | string | `en` | Language of the returned text |
| `limit` | int | `10` | Page size, clamped to 100 |
| `offset` | int | `0` | Rows to skip |

```bash
curl "https://data.kinestex.com/api/exercises/client?body_parts=Chest,Triceps&categories=Strength&difficulty_level=easy,medium&lang=es&limit=2" \
  -H "x-api-key: YOUR_API_KEY"
```

```json
{
  "exercises": [
    {
      "id": 412,
      "title": "Flexiones",
      "description": "Ejercicio de empuje para pecho y triceps.",
      "difficulty_level": "easy",
      "position": "lying",
      "calories_per_rep": 0.32,
      "is_active": true,
      "body_parts": [{ "name": "Chest" }, { "name": "Triceps" }],
      "categories": ["Strength", "Muscle Gain"],
      "contraindications": [],
      "equipment": [
        {
          "id": 3,
          "title": "Esterilla",
          "description": "Esterilla de ejercicio estandar.",
          "thumbnail_url": "https://firebasestorage.googleapis.com/.../mat.png?alt=media"
        }
      ],
      "translation_languages": ["es", "en"],
      "created_at": "2026-01-14T10:00:00Z",
      "thumbnail_url": "https://firebasestorage.googleapis.com/.../thumb.png?alt=media",
      "video_url": "https://firebasestorage.googleapis.com/.../video.mp4?alt=media",
      "male_thumbnail_url": null,
      "male_video_url": null,
      "rest_speech": "descansa-treinta-segundos-abc123",
      "rest_speech_text": "Descansa 30 segundos",
      "repeats": 12,
      "countdown": 3,
      "correct_second": 1.5,
      "tips": ["Manten el core activo."],
      "steps": ["Colocate en posicion de plancha.", "Baja el pecho hacia el suelo."],
      "common_mistakes": "Dejar caer la cadera.",
      "created_by": { "id": 1 }
    }
  ],
  "pagination": {
    "total": 42,
    "limit": 2,
    "offset": 0,
    "total_pages": 21,
    "current_page": 1,
    "has_next": true,
    "has_prev": false
  }
}
```

**Exercise fields (list view)**

| Field | Type | Description |
|-------|------|-------------|
| `id` | number | Exercise identifier |
| `title` / `description` | string | Localized text, falls back to English |
| `difficulty_level` | string | `easy`, `medium`, or `hard` |
| `position` | string | `standing`, `sitting`, or `lying`. Returned, not filterable |
| `calories_per_rep` | number | Estimated calories per repetition |
| `is_active` | bool or null | Null is treated as active (legacy rows) |
| `body_parts` | object[] | Array of `{ "name": "Chest" }` objects |
| `categories` | string[] | Flat array of category names |
| `contraindications` | string[] | Body areas where caution is needed |
| `equipment` | object[] | `id`, `title`, `description`, `thumbnail_url`, localized |
| `translation_languages` | string[] | Language codes available for this exercise |
| `thumbnail_url` / `video_url` | string | Default (female) demo assets |
| `male_thumbnail_url` / `male_video_url` | string or null | Male demo assets where produced |
| `repeats` | number or null | Default repetition count |
| `countdown` | number or null | Timer length in seconds, for timer-based exercises |
| `correct_second` | number or null | Seconds one correct repetition should take |
| `tips` | string[] | Coaching tips, localized |
| `steps` | string[] | Step-by-step instructions, localized |
| `common_mistakes` | string | Omitted when empty |
| `rest_speech` / `rest_speech_text` | string | Rest-period audio identifier and its text |
| `created_by` | object | `{ "id": <company id> }` |
| `created_at` | string | ISO 8601 timestamp |

> An exercise matching several of your requested categories or body parts appears **once**, and `pagination.total` counts distinct exercises, so paging never repeats or skips a row.

> **Known quirk:** on this endpoint `translation_languages` reports only the language you asked for plus English, because only those translation rows are loaded. To test coverage for another language, filter with `translation_languages=<code>` instead: that filter queries the full translation table and is accurate. The workout list reports the complete set.

---

**Get one exercise**

```text
GET /api/exercises/client/{id}
```

`{id}` accepts a **numeric ID** (`412`) or a **title** (`Push Ups`). Titles are normalized before matching (lowercased, non-alphanumeric characters removed), so `Push Ups`, `push-ups`, and `pushups` all resolve to the same exercise. URL-encode spaces as `%20`.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `lang` | string | `en` | Language code, overrides the headers |
| `include_audio` | bool | `true` | `false` omits the generated audio URLs and returns a lighter payload |

```bash
curl "https://data.kinestex.com/api/exercises/client/Push%20Ups?lang=es" \
  -H "x-api-key: YOUR_API_KEY"
```

```json
{
  "id": "412",
  "ai_model": { "id": "57" },
  "repeats": 12,
  "countdown": 3,
  "thumbnail_url": "https://firebasestorage.googleapis.com/.../thumb.png?alt=media",
  "video_url": "https://firebasestorage.googleapis.com/.../video.mp4?alt=media",
  "male_thumbnail_url": null,
  "male_video_url": null,
  "difficulty_level": "easy",
  "position": "lying",
  "calories_per_rep": 0.32,
  "body_parts": ["Chest", "Triceps"],
  "categories": ["Strength", "Muscle Gain"],
  "contraindications": ["shoulder"],
  "equipment": [],
  "is_active": true,
  "correct_second": 1.5,
  "non_motivational": false,
  "translation": {
    "id": 50,
    "language": "es",
    "title": "Flexiones",
    "description": "Ejercicio de empuje para pecho y triceps.",
    "tips": ["Manten el core activo."],
    "exercise_steps": ["Colocate en posicion de plancha.", "Baja el pecho hacia el suelo."],
    "common_mistakes": "Dejar caer la cadera.",
    "rest_speech": "descansa-treinta-segundos-abc123",
    "rest_speech_text": "Descansa 30 segundos",
    "rest_speech_url_m4a": "https://firebasestorage.googleapis.com/.../rest.m4a?alt=media",
    "rest_speech_url_webm": "https://firebasestorage.googleapis.com/.../rest.webm?alt=media",
    "voice_actor": "Glinda"
  },
  "created_by": { "id": 1 }
}
```

Differences from the list view, worth handling explicitly:

- `id` is a **string** here and a number in the list.
- `body_parts` is a flat `string[]` here and an array of `{name}` objects in the list.
- Localized text lives under `translation` instead of at the root, and it carries the audio URLs, `voice_actor`, and `exercise_steps` (the list calls the same data `steps`).
- `ai_model` is reduced to `{ "id": ... }`. Pass that ID to the SDK when you build a fully custom workout UI.
- Deactivated exercises are still fetchable by ID or title. The `remove_inactive` filter applies to the list only.

**Not found** returns `404`:

```json
{
  "message": "Exercise not found",
  "details": "exercise with normalized title 'unknown' not found in company or library exercises"
}
```

## Workouts

A workout is an ordered sequence of exercises and rest periods, with its own difficulty, calorie estimate, and duration.

---

**List workouts**

```text
GET /api/workouts/client
```

| Parameter | Type | Default | Matches |
|-----------|------|---------|---------|
| `search` | string | none | Partial, case-insensitive match on the English title |
| `category` | string | none | **Single value.** Whole-string, case-insensitive match |
| `dif_level` | string | none | **Single value.** Whole-string, case-insensitive match |
| `body_parts` | string[] | none | Body part name, case-insensitive |
| `translation_languages` | string[] | none | Only workouts translated into one of these language codes |
| `include_exercises` | bool | `false` | `true` expands every workout with its full exercise sequence |
| `include_shared_library` | bool | `true` | `false` returns only your company's own workouts |
| `lang` | string | `en` | Language of the returned text |
| `limit` | int | `10` | Page size, clamped to 100 |
| `offset` | int | `0` | Rows to skip |

```bash
curl "https://data.kinestex.com/api/workouts/client?category=Strength&dif_level=easy&body_parts=Chest,Triceps&limit=2" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Language: en"
```

```json
{
  "workouts": [
    {
      "id": 88,
      "category": "Strength",
      "calories": 250,
      "type": "Upper Body",
      "body_img_url": "https://firebasestorage.googleapis.com/.../body.png?alt=media",
      "dif_level": "Easy",
      "desc_img_url": "https://firebasestorage.googleapis.com/.../desc.png?alt=media",
      "total_time": 1800,
      "body_parts": ["Chest", "Triceps"],
      "translation": {
        "id": 201,
        "workout_id": 88,
        "language": "en",
        "title": "Upper Body Starter",
        "description": "A short push-focused session."
      },
      "translation_languages": ["en", "es", "de"],
      "created_by": { "id": 1 },
      "created_at": "2026-01-14T10:00:00Z",
      "updated_at": "2026-02-02T08:30:00Z"
    }
  ],
  "pagination": {
    "total": 12,
    "limit": 2,
    "offset": 0,
    "total_pages": 6,
    "current_page": 1,
    "has_next": true,
    "has_prev": false
  }
}
```

**Workout fields (list view)**

| Field | Type | Description |
|-------|------|-------------|
| `id` | number | Workout identifier |
| `category` | string | Free-text category set per workout |
| `calories` | number | Estimated calories burned |
| `type` | string or null | Optional content type label |
| `dif_level` | string | Difficulty as stored, usually capitalized |
| `body_img_url` | string or null | Body-map image |
| `desc_img_url` | string or null | Cover / preview image |
| `total_time` | number | Duration in **seconds** |
| `body_parts` | string[] | Targeted body parts |
| `translation` | object or null | Localized `title` and `description` for the requested language |
| `translation_languages` | string[] | Every language code this workout has |
| `workout_sequences` | array | Present only with `include_exercises=true` |
| `created_by` | object | `{ "id": <company id> }` |
| `created_at` / `updated_at` | string | ISO 8601 timestamps |

> **The workout title is not a root-level field.** Always read `translation.title`.

> `translation` is `null` when the requested language has no row for that workout, because this endpoint does not fall back to English. `translation_languages` always lists what does exist, so use it to decide whether to re-request in another language. Passing `include_exercises=true` switches to the detail builder, which *does* fall back to English.

`include_exercises=true` adds a `workout_sequences` array to every item with full exercise and AI-model detail. It is a much larger payload: use it for a workout player, not for a browsing list.

---

**Get one workout**

```text
GET /api/workouts/client/{id}
```

`{id}` accepts a **numeric ID** (`88`), a legacy 20-character Firestore ID, or a **title** (`Upper Body Starter`). Titles are normalized before matching, so spacing, hyphens, and casing do not matter. URL-encode spaces as `%20`.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `lang` | string | `en` | Language code, overrides the headers |
| `include_audio` | bool | `true` | `false` omits generated coaching audio URLs |

This endpoint **always** returns the full exercise sequence. There is no `include_exercises` toggle on it.

```bash
curl "https://data.kinestex.com/api/workouts/client/88?include_audio=false" \
  -H "x-api-key: YOUR_API_KEY"
```

```json
{
  "id": 88,
  "created_at": "2026-01-14T10:00:00Z",
  "updated_at": "2026-02-02T08:30:00Z",
  "category": "Strength",
  "calories": 250,
  "type": "Upper Body",
  "dif_level": "Easy",
  "desc_img_url": "https://firebasestorage.googleapis.com/.../desc.png?alt=media",
  "total_time": 1800,
  "total_minutes": 30,
  "body_parts": ["Chest", "Triceps"],
  "translation": {
    "id": 201,
    "language": "en",
    "title": "Upper Body Starter",
    "description": "A short push-focused session.",
    "dif_level": "Easy"
  },
  "auto_calculate": false,
  "turn_off_time_reminder": false,
  "turn_off_sound_reminder": false,
  "created_by": { "id": 1 },
  "workout_sequences": [
    {
      "id": 100,
      "workout_id": 88,
      "exercise_id": 0,
      "order": 1,
      "countdown": 10,
      "repeats": null,
      "video_url": null,
      "thumbnail_url": null,
      "is_rest_sequence": true,
      "created_at": "2026-01-14T10:00:00Z",
      "updated_at": "2026-01-14T10:00:00Z"
    },
    {
      "id": 101,
      "workout_id": 88,
      "exercise_id": 412,
      "order": 2,
      "countdown": null,
      "repeats": 12,
      "video_url": null,
      "thumbnail_url": null,
      "is_rest_sequence": false,
      "created_at": "2026-01-14T10:00:00Z",
      "updated_at": "2026-01-14T10:00:00Z",
      "exercise": {
        "id": "412",
        "ai_model": { "id": "57" },
        "repeats": 12,
        "countdown": null,
        "thumbnail_url": "https://firebasestorage.googleapis.com/.../thumb.png?alt=media",
        "video_url": "https://firebasestorage.googleapis.com/.../video.mp4?alt=media",
        "difficulty_level": "easy",
        "position": "lying",
        "calories_per_rep": 0.32,
        "body_parts": ["Chest", "Triceps"],
        "categories": ["Strength"],
        "is_active": true,
        "correct_second": 1.5,
        "translation": {
          "title": "Push Ups",
          "language": "en",
          "description": "A pushing movement for chest and triceps."
        },
        "created_by": { "id": 1 }
      }
    }
  ]
}
```

**Detail-only fields**

| Field | Type | Description |
|-------|------|-------------|
| `total_minutes` | number | `total_time` divided by 60, for display |
| `auto_calculate` | bool | Calories are recomputed from the exercise list |
| `turn_off_time_reminder` | bool | Time reminders disabled for this workout |
| `turn_off_sound_reminder` | bool | Sound reminders disabled for this workout |
| `workout_sequences` | array | Ordered entries, see below |

**Workout sequence entry**

| Field | Type | Description |
|-------|------|-------------|
| `order` | number | Position in the workout, ascending |
| `is_rest_sequence` | bool | `true` for a rest block; `exercise` is then absent |
| `countdown` | number or null | Timer length in seconds, for timed entries and rests |
| `repeats` | number or null | Repetition target, for rep-based entries |
| `video_url` / `thumbnail_url` | string or null | Per-sequence overrides of the exercise assets |
| `exercise` | object | Full exercise detail, same shape as `GET /api/exercises/client/{id}` |
| `exercise_id` | number | `0` on rest entries |
| `translation` | object | Rest-period speech text and audio, when the sequence has one |

**Not found** returns `404 { "error": "Workout not found" }`.

> A workout deactivated in the KinesteX library disappears from the list but stays fetchable by ID, so plans that already reference it keep working.

## Plans

A plan is a multi-week program: weeks contain days, and each non-rest day points at a workout.

---

**List plans**

```text
GET /api/plans/client
```

| Parameter | Type | Default | Matches |
|-----------|------|---------|---------|
| `search` | string | none | Partial, case-insensitive match on the English title |
| `level` | int | none | Exact match on the plan's numeric level. A non-integer returns `400` |
| `body_parts` | string[] | none | Body part name, case-insensitive |
| `translation_languages` | string[] | none | Only plans translated into one of these language codes |
| `include_weeks` | bool | `false` | `true` expands each plan with its weeks, days, and per-day workouts |
| `include_shared_library` | bool | `true` | `false` returns only your company's own plans |
| `lang` | string | `en` | Language of the returned text |
| `limit` | int | `10` | Page size, clamped to 100 |
| `offset` | int | `0` | Rows to skip |

Read the `level` field off an unfiltered page to see which levels your library actually uses. There is no `categories` filter here; each plan instead reports its own `category_levels` scores. Personal (AI-generated) plans are always excluded from this list.

```bash
curl "https://data.kinestex.com/api/plans/client?level=2&body_parts=Full%20Body&limit=2" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Language: en"
```

```json
{
  "data": [
    {
      "id": 45,
      "img_url": "https://firebasestorage.googleapis.com/.../cover.png?alt=media",
      "level": 2,
      "created_at": "2026-01-14T10:00:00Z",
      "updated_at": "2026-02-02T08:30:00Z",
      "translation": {
        "title": "Four Week Reset",
        "description": "A four-week full-body progression."
      },
      "translation_languages": ["en", "es"],
      "weeks_count": 4,
      "workout_count": 20,
      "body_parts": ["Full Body"],
      "category_levels": [
        { "name": "Strength", "score": 7 },
        { "name": "Cardio/Endurance", "score": 4 }
      ],
      "created_by": { "id": 1 }
    }
  ],
  "pagination": {
    "total": 6,
    "limit": 2,
    "offset": 0,
    "total_pages": 3,
    "current_page": 1,
    "has_next": true,
    "has_prev": false
  }
}
```

> **The plan list is keyed differently.** Plans arrive under `data`, not `plans`. Exercises use `exercises` and workouts use `workouts`.

**Plan fields (list view)**

| Field | Type | Description |
|-------|------|-------------|
| `id` | number | Plan identifier |
| `img_url` | string | Cover image |
| `level` | number | Numeric level, the value `?level=` matches against |
| `translation` | object | Localized `title` and `description`, falls back to English |
| `translation_languages` | string[] | Language codes loaded for this plan |
| `weeks_count` | number | Number of weeks |
| `workout_count` | number | Number of workout days across the plan |
| `body_parts` | string[] | Targeted body parts |
| `category_levels` | object[] | `{ "name", "score" }` per training category |
| `created_by` | object | `{ "id": <company id> }` |
| `created_at` / `updated_at` | string | ISO 8601 timestamps |
| `weeks` | array | Present only with `include_weeks=true` |

With `include_weeks=true`, each plan gains a `weeks` array of `{ week_number, title, description, days[] }`, and each day is `{ day_number, title, is_rest, is_done, workout }`. `is_done` is always `false` in list view (no progression lookup is performed).

> Same caveat as exercises: `translation_languages` on this endpoint reports the language you asked for plus English. Use `translation_languages=<code>` as a filter to test coverage accurately.

> **Known quirk:** inside `include_weeks=true`, `days[].workout.total_minutes` currently carries the workout's duration in **seconds**, not minutes. Divide by 60, or read `total_time` and `total_minutes` from `GET /api/workouts/client/{id}`, which are correct.

---

**Get one plan**

```text
GET /api/plans/client/{id}
```

`{id}` accepts a **numeric ID** (`45`), a legacy Firestore ID, or a **title** (`Four Week Reset`). Titles are normalized before matching. URL-encode spaces as `%20`.

This endpoint takes **no** query parameters. In particular it ignores `?lang=`: set the `Language` or `Accept-Language` header instead. Weeks, days, and the current workout are always included.

```bash
curl "https://data.kinestex.com/api/plans/client/45" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Language: en"
```

```json
{
  "id": "45",
  "img_url": "https://firebasestorage.googleapis.com/.../cover.png?alt=media",
  "level": "Strength",
  "is_active": true,
  "created_at": "2026-01-14T10:00:00Z",
  "updated_at": "2026-02-02T08:30:00Z",
  "title": "Four Week Reset",
  "description": "A four-week full-body progression.",
  "body_parts": ["Full Body"],
  "category_levels": [
    { "name": "Strength", "score": 7 },
    { "name": "Cardio/Endurance", "score": 4 }
  ],
  "currentWorkout": {
    "id": 88,
    "category": "Strength",
    "calories": 250,
    "dif_level": "Easy",
    "total_time": 1800,
    "total_minutes": 30,
    "translation": { "title": "Upper Body Starter", "language": "en", "description": "A short push-focused session." },
    "workout_sequences": [...]
  },
  "weeks": [
    {
      "id": 30,
      "week_number": 1,
      "title": "Foundation Week",
      "description": "Build your base strength.",
      "intensity": 3,
      "rest_multiplier": 1,
      "isActive": true,
      "isComplete": false,
      "translation": { "id": 100, "language": "en", "title": "Foundation Week", "description": "Build your base strength." },
      "days": [
        {
          "id": 100,
          "day_number": 1,
          "is_rest": false,
          "title": "Day 1",
          "isCompleted": false,
          "isActive": true,
          "translation": { "id": 200, "language": "en", "title": "Day 1" },
          "workout": {
            "id": "88",
            "img_url": "https://firebasestorage.googleapis.com/.../desc.png?alt=media",
            "title": "Upper Body Starter",
            "description": "A short push-focused session.",
            "calories": 250,
            "total_minutes": 30
          }
        },
        {
          "id": 101,
          "day_number": 2,
          "is_rest": true,
          "title": "Rest Day",
          "isCompleted": false,
          "isActive": false,
          "translation": { "id": 201, "language": "en", "title": "Rest Day" },
          "workout": null
        }
      ]
    }
  ],
  "created_by": { "id": 1 }
}
```

**Plan fields (detail view)**

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Plan identifier, a **string** here and a number in the list |
| `level` | string | The name of the highest-scoring category (`"Strength"`, `"Cardio"`, ...), or the numeric level as a string when no category matches. Not the same shape as the list's numeric `level` |
| `is_active` | bool | Catalog visibility of the plan itself, unrelated to user progress |
| `title` / `description` | string | Localized, at the root rather than under `translation` |
| `currentWorkout` | object or null | The next workout to play, as a **full workout object** with its sequence, identical in shape to `GET /api/workouts/client/{id}` |
| `body_parts` | string[] | Targeted body parts |
| `category_levels` | object[] | `{ "name", "score" }` per training category |
| `weeks` | object[] | Weeks with nested days, see below |

**Week object**

| Field | Type | Description |
|-------|------|-------------|
| `week_number` | number | 1-indexed position |
| `title` / `description` | string | Localized, also mirrored under `translation` |
| `intensity` | number or null | Relative intensity of the week |
| `rest_multiplier` | number or null | Rest-period multiplier applied to this week |
| `isActive` | bool | This is the week the user is on |
| `isComplete` | bool | Every workout day in the week is completed |
| `days` | object[] | Days in ascending `day_number` |

**Day object**

| Field | Type | Description |
|-------|------|-------------|
| `day_number` | number | 1-indexed position within the week |
| `is_rest` | bool | Rest day, `workout` is `null` |
| `title` | string | Localized day title |
| `isCompleted` | bool | The user has completed this day |
| `isActive` | bool | This is the day the user is on |
| `workout` | object or null | Summary: `id`, `title`, `description`, `img_url`, `calories`, `total_minutes` |

> **Progress flags depend on who is asking.** With a user JWT, or an API key plus `x-user-id`, `isActive` / `isComplete` / `isCompleted` reflect that user's real progression. With a bare API key there is no user, so the plan reads as not started: nothing is completed and the first day of week 1 is reported as active. Treat the flags as presentation state, not as a source of truth for your own analytics.

**Not found** returns `404`:

```json
{
  "error": "Plan not found",
  "message": "No plan found matching '4-Week Strength Plan'"
}
```

## Pagination & Caching

**Pagination**

The `pagination` object is identical on all three list endpoints and is always present, even when the page is empty.

```json
{
  "total": 42,
  "limit": 20,
  "offset": 0,
  "total_pages": 3,
  "current_page": 1,
  "has_next": true,
  "has_prev": false
}
```

| Field | Meaning |
|-------|---------|
| `total` | Distinct rows matching every filter |
| `limit` | Page size actually applied |
| `offset` | Rows skipped |
| `total_pages` | Pages at the current `limit` |
| `current_page` | Derived from `offset / limit`, 1-based |
| `has_next` / `has_prev` | Whether another page exists in that direction |

- `limit` above 100 is silently clamped to 100. A zero, negative, or non-numeric value falls back to 10.
- A negative or non-numeric `offset` falls back to 0.
- `total` counts distinct rows, so it is safe to drive an infinite scroll from it.

Walking every page:

```javascript
async function fetchAllExercises(params = {}) {
  const items = [];
  let offset = 0;
  const limit = 50;

  for (;;) {
    const query = new URLSearchParams({ ...params, limit, offset });
    const res = await fetch(
      `https://data.kinestex.com/api/exercises/client?` + query,
      { headers: { "x-api-key": process.env.KINESTEX_API_KEY, Language: "en" } }
    );
    if (!res.ok) throw new Error(`Content API ` + res.status);

    const page = await res.json();
    items.push(...page.exercises);
    if (!page.pagination.has_next) return items;
    offset += limit;
  }
}
```

**Caching**

List responses are cached for up to **5 minutes**, keyed per company, per language, and per exact query string, so two different filter combinations never serve each other's results. Single-item responses are cached for up to **15 minutes**. Both caches are invalidated when the underlying content is edited, so in normal use you see changes quickly; allow for the window when you write tests that create content and immediately list it.

**Performance tips**

- Keep `include_exercises` and `include_weeks` at their `false` default for browsing lists. Turn them on only for the screen that actually plays the content.
- Prefer server-side filters over fetching everything and filtering client-side. Every filter listed here runs in SQL.
- Keep page sizes moderate (10 to 20 for a UI, up to 100 for a sync job).
- Use `translation_languages` as a filter when you need a fully localized catalog, rather than fetching everything and discarding items with a null `translation`.

## Errors & Access Rules

**Errors**

Every error is a JSON object with an `error` string, sometimes with `details` or `message` alongside it.

| Status | Body | Cause |
|--------|------|-------|
| `400` | `{"error": "Invalid level value: must be an integer"}` | Non-numeric `?level=` on the plan list |
| `401` | `{"error": "Unauthorized"}` | No `x-api-key` and no `Authorization` header |
| `401` | `{"error": "Invalid API key"}` | The key is not recognized |
| `403` | `{"error": "User does not belong to this company"}` | The `x-user-id` you sent belongs to another company |
| `404` | `{"error": "User not found"}` | The `x-user-id` you sent does not exist yet |
| `404` | `{"error": "Workout not found"}` | No workout matches that ID or title in your scope |
| `404` | `{"message": "Exercise not found", "details": "..."}` | No exercise matches that ID or title in your scope |
| `404` | `{"error": "Plan not found", "message": "..."}` | No plan matches that ID or title in your scope |
| `429` | `{"error": "<limit message>"}` | Your company's daily read quota is spent |
| `500` | `{"error": "Failed to fetch exercises"}` | Server-side failure, safe to retry |

An unrecognized **filter value** is not an error. You get `200` with an empty array and `pagination.total: 0`.

A sane client treats `401` and `403` as configuration bugs (do not retry), `404` as "this content is not in your catalog", `429` as back-off-and-retry-later, and `500` as retry with exponential backoff.

---

**Access rules**

You see two pools of content:

1. **Your own content**, created by your company in the KinesteX admin panel or through the API.
2. **The shared KinesteX library**, included by default on every list.

| Setting | Effect |
|---------|--------|
| `include_shared_library=true` (default) | Your content plus the active shared library |
| `include_shared_library=false` | Your content only |

`include_shared_library=false` removes the shared library, never your own content.

**Deactivated content**

| Catalog | Your own deactivated items | Deactivated shared-library items |
|---------|----------------------------|----------------------------------|
| Exercises | Hidden unless `remove_inactive=false` | Always hidden |
| Workouts | Visible in lists | Always hidden from lists |
| Plans | Visible in lists | Always hidden from lists |

Single-item fetches by ID are deliberately **not** gated on the active flag: content a plan or a saved workout already references keeps resolving after it is retired from the catalog.

Workouts generated for individual users' personalized plans are excluded from the shared-library listing, and personal (AI-generated) plans are excluded from the plan list entirely.

---

**Rate limits**

Content reads count against your company's daily read quota. When it is exhausted the API returns `429` with the limit message in `error`. If you are running a bulk sync and hit it, contact KinesteX to raise the quota rather than retrying in a tight loop.

## Migrating from the Legacy API

The [legacy Content API](/docs/content-api) (`https://admin.kinestex.com/api/v1/`) and the SDK convenience methods built on it still work. New integrations should use the endpoints on this page.

**What moves**

| Legacy | Updated |
|--------|---------|
| `https://admin.kinestex.com/api/v1/` | `https://data.kinestex.com/api/` |
| `x-api-key` + `x-company-name` headers | `x-api-key` alone (the key identifies the company) |
| `/workouts`, `/plans`, `/exercises` | `/workouts/client`, `/plans/client`, `/exercises/client` |
| `lastDocId` cursor paging | `limit` + `offset` with a full `pagination` block |
| Client-side filtering | `search`, `body_parts`, `categories`, `difficulty_level`, `level`, `translation_languages` |
| Firestore document IDs | Numeric IDs (legacy 20-character Firestore IDs still resolve) |

**Response shape differences to expect**

- Lists are keyed `exercises`, `workouts`, and `data` (plans), each alongside `pagination`.
- Workout and plan list titles live under `translation.title`, not at the root.
- `total_time` on workouts is in seconds; `total_minutes` is the minutes value and appears on the detail endpoints.
- Exercise `id` is a number in list responses and a string in single-item responses. Plan `id` behaves the same way.
- `created_by` is reduced to `{ "id": ... }` and internal fields (`firestore_id`, `created_by_id`, `ai_model_id`, full AI-model configuration) are stripped from every `/client` response.

**A safe migration order**

1. Point your list screens at the new endpoints and drive paging from `pagination`.
2. Replace client-side filtering with the query parameters, and delete the "fetch everything" code path.
3. Switch detail screens to `/{id}`, passing the numeric ID you now get from the list.
4. Move localization to the `Language` header plus `?lang=`, and drop any hard-coded language handling.

If you are still on the SDK convenience methods (`fetchWorkouts()`, `fetchPlans()`, `fetchExercises()`), keep using them: they are supported, and the [legacy page](/docs/content-api) documents them in full. Reach out to KinesteX when you want the SDK helpers pointed at these endpoints.

---
Source: https://www.kinestex.com/docs/content-api-v2 · Index: https://www.kinestex.com/llms.txt
