# Multi-Language Responses

Fetch the same exercise, workout, or plan in up to **three languages** with one request. All six endpoints support it, and requests that do not opt in are unchanged.

---

**Opting in**

List the language codes in the `langs` query parameter or in the `X-Languages` header:

```bash
curl "https://data.kinestex.com/api/workouts/client/88?langs=en,es,de" \
  -H "x-api-key: YOUR_API_KEY"
```

```bash
curl "https://data.kinestex.com/api/workouts/client/88" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "X-Languages: en,es,de"
```

`langs` takes the same three array forms as every other list filter (`?langs=en,es,de`, `?langs=en&langs=es&langs=de`, `?langs[]=en&langs[]=es&langs[]=de`). `X-Languages` is a comma-separated list. When both are present, `langs` wins.

- **Codes are normalized and de-duplicated**, in the order you sent them. `es`, `ES`, and `es-MX` collapse to a single `es` entry, and a `;q=` weight is ignored, so a device locale list such as `en-US,fr;q=0.8` asks for `en` and `fr`.
- **At most three distinct languages.** A fourth returns `400`.
- **An empty value does not opt in.** `?langs=` or `?langs=,,` is ignored and the request takes the single-language path (or the `X-Languages` header, if you sent one).
- **Every other parameter still applies**, to every language: `limit`, `offset`, `search`, `body_parts`, `include_shared_library`, `include_audio`, and the rest. Pagination totals are the same in every entry, because no filter depends on language.

> **Only `langs` and `X-Languages` opt in.** `lang`, `Language`, and `Accept-Language` keep their single-language meaning, comma lists included: `?lang=en,es` still resolves to one language and returns the flat body it always did. The reverse also holds: `?langs=es` with a single code returns the wrapped shape below. The shape depends on which parameter you use, never on how many languages you list.

**Response shape**

```json
{
  "languages": ["en", "es", "de"],
  "results": [
    { "language": "en", "translation_available": true, "data": { ... } },
    { "language": "es", "translation_available": true, "data": { ... } },
    {
      "language": "de",
      "translation_available": false,
      "translation_message": "Translation not available for language 'de'",
      "data": { ... }
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `languages` | string[] | The normalized codes, in request order |
| `results` | object[] | One entry per language, in the same order |
| `results[].language` | string | The language this entry holds |
| `results[].translation_available` | bool | Whether the item (on a list: every item on the page) has a translation in this language |
| `results[].translation_message` | string | Present only when `translation_available` is `false`. Says what is missing |
| `results[].data` | object | Exactly the body a single-language request for that language returns: same keys, same nesting, same English fallback |

`data` is produced by the same code that serves the single-language endpoint, so everything that code localizes (titles, descriptions, tips, steps, workout sequences, plan weeks and days) is localized here too, and every per-endpoint rule on the [Exercises](/docs/content-api-v2/content-api-v2-exercises), [Workouts](/docs/content-api-v2/content-api-v2-workouts), and [Plans](/docs/content-api-v2/content-api-v2-plans) pages applies unchanged inside it. A parser you already have for the single-language body works on `results[i].data`. Keys inside `data` come back in alphabetical order rather than the single-language order; the keys and values themselves are identical.

**Example: one exercise in three languages**

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

```json
{
  "languages": ["en", "es", "ru"],
  "results": [
    {
      "language": "en",
      "translation_available": true,
      "data": {
        "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": 49,
          "language": "en",
          "title": "Push Ups",
          "description": "A pushing movement for chest and triceps.",
          "tips": ["Keep your core tight."],
          "exercise_steps": ["Start in a plank position.", "Lower your chest toward the floor."],
          "common_mistakes": "Letting the hips sag.",
          "rest_speech": "rest-thirty-seconds-abc123",
          "rest_speech_text": "Rest for 30 seconds",
          "rest_speech_url_m4a": "https://firebasestorage.googleapis.com/.../rest-en.m4a?alt=media",
          "rest_speech_url_webm": "https://firebasestorage.googleapis.com/.../rest-en.webm?alt=media",
          "voice_actor": "Glinda"
        },
        "created_by": { "id": 1 }
      }
    },
    {
      "language": "es",
      "translation_available": true,
      "data": {
        "id": "412",
        "ai_model": { "id": "57" },
        ...
        "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-es.m4a?alt=media",
          "rest_speech_url_webm": "https://firebasestorage.googleapis.com/.../rest-es.webm?alt=media",
          "voice_actor": "Glinda"
        },
        "created_by": { "id": 1 }
      }
    },
    {
      "language": "ru",
      "translation_available": false,
      "translation_message": "Translation not available for language 'ru'",
      "data": {
        "id": "412",
        "ai_model": { "id": "57" },
        ...
        "translation": {
          "id": 49,
          "language": "en",
          "title": "Push Ups",
          "description": "A pushing movement for chest and triceps.",
          ...
        },
        "created_by": { "id": 1 }
      }
    }
  ]
}
```

The `ru` entry holds the English fallback the single-language endpoint would have returned for `?lang=ru`, which is why its `translation.language` reads `en`. The flag and message are how you tell a fallback from a real translation without inspecting `data`.

**Example: a list with partial coverage**

On the list endpoints, availability is reported **per item**. Every item inside `data.exercises`, `data.workouts`, or `data.data` (plans) gains a `translation_available` boolean, and items missing the language also gain a `translation_message`. The entry-level `translation_available` is `true` only when every item on the page has the language; otherwise its `translation_message` says how many are missing.

```bash
curl "https://data.kinestex.com/api/workouts/client?langs=en,de&limit=2" \
  -H "x-api-key: YOUR_API_KEY"
```

```json
{
  "languages": ["en", "de"],
  "results": [
    {
      "language": "en",
      "translation_available": true,
      "data": {
        "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"],
            "translation_available": true,
            "created_by": { "id": 1 },
            "created_at": "2026-01-14T10:00:00Z",
            "updated_at": "2026-02-02T08:30:00Z"
          },
          {
            "id": 91,
            "category": "Cardio",
            "calories": 320,
            "type": null,
            "body_img_url": null,
            "dif_level": "Medium",
            "desc_img_url": "https://firebasestorage.googleapis.com/.../hiit.png?alt=media",
            "total_time": 1200,
            "body_parts": ["Full Body"],
            "translation": {
              "id": 230,
              "workout_id": 91,
              "language": "en",
              "title": "20-Minute HIIT",
              "description": "Intervals for the whole body."
            },
            "translation_languages": ["en", "es"],
            "translation_available": true,
            "created_by": { "id": 1 },
            "created_at": "2026-01-20T10:00:00Z",
            "updated_at": "2026-01-20T10:00:00Z"
          }
        ],
        "pagination": {
          "total": 12,
          "limit": 2,
          "offset": 0,
          "total_pages": 6,
          "current_page": 1,
          "has_next": true,
          "has_prev": false
        }
      }
    },
    {
      "language": "de",
      "translation_available": false,
      "translation_message": "Translation not available for language 'de' for 1 of 2 items on this page; see translation_available on each item",
      "data": {
        "workouts": [
          {
            "id": 88,
            "category": "Strength",
            ...
            "translation": {
              "id": 205,
              "workout_id": 88,
              "language": "de",
              "title": "Brust und Trizeps Einstieg",
              "description": "Eine kurze Einheit mit Fokus auf Druckbewegungen."
            },
            "translation_languages": ["en", "es", "de"],
            "translation_available": true,
            "created_by": { "id": 1 },
            ...
          },
          {
            "id": 91,
            "category": "Cardio",
            ...
            "translation": null,
            "translation_languages": ["en", "es"],
            "translation_available": false,
            "translation_message": "Translation not available for language 'de'",
            "created_by": { "id": 1 },
            ...
          }
        ],
        "pagination": {
          "total": 12,
          "limit": 2,
          "offset": 0,
          "total_pages": 6,
          "current_page": 1,
          "has_next": true,
          "has_prev": false
        }
      }
    }
  ]
}
```

Workout 91 has no German row, so inside the `de` entry it carries `translation: null`, exactly as the [workout list](/docs/content-api-v2/content-api-v2-workouts) does for a single-language request. On the exercise and plan lists the same item would instead carry English text, because those endpoints fall back to English.

**What `translation_available` measures**

- It is decided on the top-level item (the exercise, workout, or plan) from its translation rows. It is a complete lookup, so it is accurate even on the exercise and plan lists, where `translation_languages` only reports the requested language plus English.
- Nested content (exercises inside a workout, workouts inside a plan) follows the endpoint's usual per-field fallback and is not reflected in the flag.
- Legacy Firestore-backed workouts and plans have no translation rows. For those, only `en` is reported as available.
- A translation saved seconds ago can briefly report as unavailable, because the check reads from a replica. Treat the flag as a display hint, not as a source of truth for coverage reports.

**Errors**

| Status | Body | Cause |
|--------|------|-------|
| `400` | `{"error": "Too many languages requested: 4 (maximum 3)"}` | More than three distinct codes |

Every other error is returned exactly as the single-language endpoint returns it, with the same status and body, never wrapped in `results`. Lookups are language-independent (titles are matched in English and only rendered in the requested language), so a `404` for one language is a `404` for all of them. See [Errors & Access Rules](/docs/content-api-v2/content-api-v2-errors) for the full list.

**Reading the response in code**

```javascript
const res = await fetch(
  "https://data.kinestex.com/api/workouts/client/88?langs=en,es",
  { headers: { "x-api-key": process.env.KINESTEX_API_KEY } }
);
if (!res.ok) throw new Error("Content API " + res.status);

const { results } = await res.json();
const byLanguage = Object.fromEntries(results.map((r) => [r.language, r]));

// Show Spanish when it exists, otherwise the English entry.
const entry = byLanguage.es.translation_available ? byLanguage.es : byLanguage.en;
const workout = entry.data;
console.log(workout.translation.title);
```

Each `results[i].data` is a normal workout, so the rest of your rendering code does not change. The same pattern works on the list endpoints: index `data.workouts` (or `data.exercises`, `data.data`) by `id` to switch languages per item without re-fetching.

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