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:
1curl "https://data.kinestex.com/api/workouts/client/88?langs=en,es,de" \
2 -H "x-api-key: YOUR_API_KEY"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, andes-MXcollapse to a singleesentry, and a;q=weight is ignored, so a device locale list such asen-US,fr;q=0.8asks forenandfr. - 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 theX-Languagesheader, 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.
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
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}| 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, 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
1curl "https://data.kinestex.com/api/exercises/client/Push%20Ups?langs=en,es,ru" \
2 -H "x-api-key: YOUR_API_KEY"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.
1curl "https://data.kinestex.com/api/workouts/client?langs=en,de&limit=2" \
2 -H "x-api-key: YOUR_API_KEY"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_languagesonly 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
enis 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 for the full list.
Reading the response in code
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.