KinesteX

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
1curl "https://data.kinestex.com/api/workouts/client/88?langs=en,es,de" \
2  -H "x-api-key: YOUR_API_KEY"

bash
1curl "https://data.kinestex.com/api/workouts/client/88" \
2  -H "x-api-key: YOUR_API_KEY" \
3  -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
1{
2  "languages": ["en", "es", "de"],
3  "results": [
4    { "language": "en", "translation_available": true, "data": { ... } },
5    { "language": "es", "translation_available": true, "data": { ... } },
6    {
7      "language": "de",
8      "translation_available": false,
9      "translation_message": "Translation not available for language 'de'",
10      "data": { ... }
11    }
12  ]
13}

FieldTypeDescription
languagesstring[]The normalized codes, in request order
resultsobject[]One entry per language, in the same order
results[].languagestringThe language this entry holds
results[].translation_availableboolWhether the item (on a list: every item on the page) has a translation in this language
results[].translation_messagestringPresent only when translation_available is false. Says what is missing
results[].dataobjectExactly 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, Workouts, and 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
1curl "https://data.kinestex.com/api/exercises/client/Push%20Ups?langs=en,es,ru" \
2  -H "x-api-key: YOUR_API_KEY"

json
1{
2  "languages": ["en", "es", "ru"],
3  "results": [
4    {
5      "language": "en",
6      "translation_available": true,
7      "data": {
8        "id": "412",
9        "ai_model": { "id": "57" },
10        "repeats": 12,
11        "countdown": 3,
12        "thumbnail_url": "https://firebasestorage.googleapis.com/.../thumb.png?alt=media",
13        "video_url": "https://firebasestorage.googleapis.com/.../video.mp4?alt=media",
14        "male_thumbnail_url": null,
15        "male_video_url": null,
16        "difficulty_level": "easy",
17        "position": "lying",
18        "calories_per_rep": 0.32,
19        "body_parts": ["Chest", "Triceps"],
20        "categories": ["Strength", "Muscle Gain"],
21        "contraindications": ["shoulder"],
22        "equipment": [],
23        "is_active": true,
24        "correct_second": 1.5,
25        "non_motivational": false,
26        "translation": {
27          "id": 49,
28          "language": "en",
29          "title": "Push Ups",
30          "description": "A pushing movement for chest and triceps.",
31          "tips": ["Keep your core tight."],
32          "exercise_steps": ["Start in a plank position.", "Lower your chest toward the floor."],
33          "common_mistakes": "Letting the hips sag.",
34          "rest_speech": "rest-thirty-seconds-abc123",
35          "rest_speech_text": "Rest for 30 seconds",
36          "rest_speech_url_m4a": "https://firebasestorage.googleapis.com/.../rest-en.m4a?alt=media",
37          "rest_speech_url_webm": "https://firebasestorage.googleapis.com/.../rest-en.webm?alt=media",
38          "voice_actor": "Glinda"
39        },
40        "created_by": { "id": 1 }
41      }
42    },
43    {
44      "language": "es",
45      "translation_available": true,
46      "data": {
47        "id": "412",
48        "ai_model": { "id": "57" },
49        ...
50        "translation": {
51          "id": 50,
52          "language": "es",
53          "title": "Flexiones",
54          "description": "Ejercicio de empuje para pecho y triceps.",
55          "tips": ["Manten el core activo."],
56          "exercise_steps": ["Colocate en posicion de plancha.", "Baja el pecho hacia el suelo."],
57          "common_mistakes": "Dejar caer la cadera.",
58          "rest_speech": "descansa-treinta-segundos-abc123",
59          "rest_speech_text": "Descansa 30 segundos",
60          "rest_speech_url_m4a": "https://firebasestorage.googleapis.com/.../rest-es.m4a?alt=media",
61          "rest_speech_url_webm": "https://firebasestorage.googleapis.com/.../rest-es.webm?alt=media",
62          "voice_actor": "Glinda"
63        },
64        "created_by": { "id": 1 }
65      }
66    },
67    {
68      "language": "ru",
69      "translation_available": false,
70      "translation_message": "Translation not available for language 'ru'",
71      "data": {
72        "id": "412",
73        "ai_model": { "id": "57" },
74        ...
75        "translation": {
76          "id": 49,
77          "language": "en",
78          "title": "Push Ups",
79          "description": "A pushing movement for chest and triceps.",
80          ...
81        },
82        "created_by": { "id": 1 }
83      }
84    }
85  ]
86}

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
1curl "https://data.kinestex.com/api/workouts/client?langs=en,de&limit=2" \
2  -H "x-api-key: YOUR_API_KEY"

json
1{
2  "languages": ["en", "de"],
3  "results": [
4    {
5      "language": "en",
6      "translation_available": true,
7      "data": {
8        "workouts": [
9          {
10            "id": 88,
11            "category": "Strength",
12            "calories": 250,
13            "type": "Upper Body",
14            "body_img_url": "https://firebasestorage.googleapis.com/.../body.png?alt=media",
15            "dif_level": "Easy",
16            "desc_img_url": "https://firebasestorage.googleapis.com/.../desc.png?alt=media",
17            "total_time": 1800,
18            "body_parts": ["Chest", "Triceps"],
19            "translation": {
20              "id": 201,
21              "workout_id": 88,
22              "language": "en",
23              "title": "Upper Body Starter",
24              "description": "A short push-focused session."
25            },
26            "translation_languages": ["en", "es", "de"],
27            "translation_available": true,
28            "created_by": { "id": 1 },
29            "created_at": "2026-01-14T10:00:00Z",
30            "updated_at": "2026-02-02T08:30:00Z"
31          },
32          {
33            "id": 91,
34            "category": "Cardio",
35            "calories": 320,
36            "type": null,
37            "body_img_url": null,
38            "dif_level": "Medium",
39            "desc_img_url": "https://firebasestorage.googleapis.com/.../hiit.png?alt=media",
40            "total_time": 1200,
41            "body_parts": ["Full Body"],
42            "translation": {
43              "id": 230,
44              "workout_id": 91,
45              "language": "en",
46              "title": "20-Minute HIIT",
47              "description": "Intervals for the whole body."
48            },
49            "translation_languages": ["en", "es"],
50            "translation_available": true,
51            "created_by": { "id": 1 },
52            "created_at": "2026-01-20T10:00:00Z",
53            "updated_at": "2026-01-20T10:00:00Z"
54          }
55        ],
56        "pagination": {
57          "total": 12,
58          "limit": 2,
59          "offset": 0,
60          "total_pages": 6,
61          "current_page": 1,
62          "has_next": true,
63          "has_prev": false
64        }
65      }
66    },
67    {
68      "language": "de",
69      "translation_available": false,
70      "translation_message": "Translation not available for language 'de' for 1 of 2 items on this page; see translation_available on each item",
71      "data": {
72        "workouts": [
73          {
74            "id": 88,
75            "category": "Strength",
76            ...
77            "translation": {
78              "id": 205,
79              "workout_id": 88,
80              "language": "de",
81              "title": "Brust und Trizeps Einstieg",
82              "description": "Eine kurze Einheit mit Fokus auf Druckbewegungen."
83            },
84            "translation_languages": ["en", "es", "de"],
85            "translation_available": true,
86            "created_by": { "id": 1 },
87            ...
88          },
89          {
90            "id": 91,
91            "category": "Cardio",
92            ...
93            "translation": null,
94            "translation_languages": ["en", "es"],
95            "translation_available": false,
96            "translation_message": "Translation not available for language 'de'",
97            "created_by": { "id": 1 },
98            ...
99          }
100        ],
101        "pagination": {
102          "total": 12,
103          "limit": 2,
104          "offset": 0,
105          "total_pages": 6,
106          "current_page": 1,
107          "has_next": true,
108          "has_prev": false
109        }
110      }
111    }
112  ]
113}

Workout 91 has no German row, so inside the de entry it carries translation: null, exactly as the workout list 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


StatusBodyCause
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 for the full list.


Reading the response in code


javascript
1const res = await fetch(
2  "https://data.kinestex.com/api/workouts/client/88?langs=en,es",
3  { headers: { "x-api-key": process.env.KINESTEX_API_KEY } }
4);
5if (!res.ok) throw new Error("Content API " + res.status);
6
7const { results } = await res.json();
8const byLanguage = Object.fromEntries(results.map((r) => [r.language, r]));
9
10// Show Spanish when it exists, otherwise the English entry.
11const entry = byLanguage.es.translation_available ? byLanguage.es : byLanguage.en;
12const workout = entry.data;
13console.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.