Zuletzt aktualisiert:

RECHERCHE-EGYM.md — eGYM / Netpulse API (Iserlohner Fitness & Sportpark)

Reverse-Engineering der App „IFS Fitness" (com.netpulse.iserlohnerfitnesspark, Build 112 / v1.9) per MITM (mitmproxy 12, iPhone 16). Quelle: Flow-Dump egym (349 HTTP-Flows, 06.06.2026, eingeloggt als Dennis). Diese Datei ist die Daten-Grundlage für den geplanten „Aktivitäten"-Screen (eGYM-Strang). Garmin ist ein separater Strang (inoffizielle Library, kein MITM) — hier nicht behandelt.

⚠️ Secrets: Alle Bearer-/Session-Tokens, deviceUid, Push-Token und Passcodes sind in diesem Dokument redigiert (<REDIGIERT>). Die enthaltenen IDs (exerciserId, egymAccountId, gymLocationId …) sind die der Familie Fisch bzw. des Studios — behandeln wie .env-Inhalt, nicht öffentlich machen.


1. Die große Erkenntnis: ZWEI Backends, ZWEI Auth-Welten

Die App spricht zwei voneinander unabhängige Systeme an, die unterschiedlich authentifizieren. Das ist der wichtigste Punkt — wer das mischt, scheitert.

Netpulse (Studio-App-Plattform) eGYM MWA (Geräte/Waage)
Hosts iserlohnerfitnesspark.netpulse.com, mobile-api.int.api.egym.com mwa-api.int.api.egym.com
Auth Session-Cookie JSESSIONID=<REDIGIERT> Authorization: Bearer <Firebase-JWT>
Identität exerciserId (Netpulse-UUID) egymAccountId + membershipId (im JWT)
Inhalt Profil, Mitgliedschaft, Check-ins (Besuche), Ziele, Punkte/Rewards, Ranking, Feed, Challenges Körperwaage (FITHUB), Kraft je Gerät, Workouts, Bio-Alter, Muskel-Imbalancen, Cardio, Flexibilität
mwa = My eGym Web App"

mobile-api.int.api.egym.com ist eGYM-gehostet, nutzt aber denselben Netpulse-Cookie wie der Haupthost (Feed, Ranking, Machine-Challenges) — gehört auth-seitig zu Netpulse.

Für unsere App heißt das: zwei Connector-Module bzw. zwei Token-Quellen pro User. Die „Ground-Truth"-Körperdaten (Anforderung #6) liegen ausschließlich im eGYM-MWA-Strang (source: "FITHUB").


2. Authentifizierung

Komplette Auth-Kette (verifiziert per Login-Capture egym-login, 06.06.2026): 1. POST /np/exerciser/login (username + password, form-urlencoded) → Set-Cookie: JSESSIONID ← der DAUERHAFTE Credential 2. GET /np/micro-web-app/v1.0/exercisers/{exerciserId}/tokens/FLS (mit JSESSIONID) → { accessToken, accessTokenExpiresAt } ← IST der MWA-Bearer (Firebase, ~1 h) 3. Authorization: Bearer {accessToken} gegen mwa-api.int.api.egym.com 4. Token (1 h) abgelaufen? → Schritt 2 wiederholen. Session tot? → Schritt 1. Kein Google-/Auth0-Refresh-Token nötig (anders als HelloFresh ADR-22): der langlebige Credential ist die Netpulse-Session; der 1-h-MWA-Token wird bei Bedarf frisch aus tokens/FLS gezogen. Pro User je ein Login (Anforderung #4).

Login (Cookie-Mint), verifiziert: - Optionaler Pre-Check: GET /np/egym/v1.0/users?email=<urlenc>{firstName, termsAccepted, passwordSet, gymLocationData} (prüft, ob E-Mail bekannt). - POST /np/exerciser/login - Content-Type: application/x-www-form-urlencoded - Body: password=<PASSWORT>&username=<email-urlenc> (+ die X-NP-*-Header) - Antwort: Set-Cookie: JSESSIONID=<…> (→ Session) und JSON mit uuid (= exerciserId), egymAccountId, homeClubUuid, chainUuid, membershipType. - ⚠️ Felder externalAuthToken / externalRefreshToken / externalIdToken im Login-Body waren null — die eGYM-Tokens kommen NICHT aus dem Login, sondern aus tokens/FLS (§2.2). - ⚠️ username/password im Klartext POSTen → in unserer DB die eGYM-Credentials je Profil verschlüsselt ablegen (oder nur die JSESSIONID + bei Ablauf neu einloggen). Niemals automatisiert mit gespeichertem Passwort, falls vermeidbar — Muster wie Lidl (Dennis/Jaqueline hinterlegen selbst). Entscheidung beim Datenmodell.

2.2 eGYM-MWA-Token (Firebase-JWT)

2.3 Identifier-Spickzettel (Familie Fisch / Studio)

Name Wert Bedeutung
exerciserId (Dennis) c995da83-bf36-4a3b-948a-75ad4601e2d6 Netpulse-User-UUID (in fast jedem Netpulse-Pfad)
egymAccountId (Dennis) 3481a20b-10aa-5fb7-8bb3-174dfa15cefb eGYM-Account (im Bearer-JWT)
membershipId (Dennis) vcw7jlwbjd74 eGYM-Membership (im Bearer-JWT)
gymLocationId / Club cec51c99-15cb-4659-b30c-6075c31b928d Studio (Query-Param bei MWA-Workouts/Feed/Features)
gymChainId 38a6c221-9911-44f0-84b2-de5263f92d38 Kette
brandId b43d8104-93b7-4068-b6eb-688e4b5a8c0b Marke (im JWT)

Jeder User hat eigene exerciserId/egymAccountId (Anforderung #4): Jaqueline bekommt eigene IDs + eigenen Login/Token. In unserer DB pro Silverscale-Profil hinterlegen.


3. Endpoint-Katalog

Legende: 🟦 = Netpulse-Cookie-Auth · 🟩 = eGYM-MWA-Bearer-Auth. Basis-URLs weggelassen (siehe §1). Alle Antworten application/json.

3.1 Auth, Profil & Stammdaten

Endpoint Auth Liefert
GET /np/egym/v1.0/users?email=… Pre-Login-Check (passwordSet, firstName, Club)
POST /np/exerciser/login LoginSet-Cookie: JSESSIONID + uuid/egymAccountId (§2.1)
GET /np/micro-web-app/v1.0/exercisers/{exerciserId}/tokens/FLS 🟦 MWA-Bearer-Mint (accessToken = Firebase-JWT, §2.2)
GET /np/exerciser/{exerciserId}/profile 🟦 Name, E-Mail, Geburtstag, Geschlecht, height/weight (Selbstangabe!), Avatar-URL, Home-Club
GET /np/exerciser/{exerciserId}/membership 🟦 Vertrag (membershipType, contractEndDate, …)
GET /np/egym/v1.0/users/{exerciserId}/linking-status 🟦 eGYM-Verknüpfung + egymEmail
GET /mwa/api/users/v1.0/profile 🟩 eGYM-Profil (birthDate, gender, name, email)
GET /np/company/children?responseType=detail 🟦 Studio-Stammdaten (Adresse, Öffnungszeiten, Geo)

profile.weight/height = Selbstangabe (79.9 kg / 172 cm), NICHT die Waage — für „Ground Truth" die MWA-Körperdaten nehmen (§3.4).

3.2 Besuche / Check-ins 🟦

Endpoint Liefert
GET /np/exercisers/{exerciserId}/latest-check-in letzter Check-in (Datum, TZ)
GET /np/exercisers/{exerciserId}/check-ins/history?startDate=…&endDate=… Liste aller Studio-Besuche im Zeitraum

check-ins/history-Item:

{ "gymLocationName":"Iserlohner Fitness und Sportpark",
  "gymLocationAddress":"Auf der Aeumes 35",
  "checkInDate":"2025-04-30T07:14:33", "timezone":"Europe/Berlin",
  "checkinId":null, "expiresIn":null, "duration":null }

3.3 Kraft je Gerät (Workouts an EGYM-Maschinen) 🟩

Kern für „Detailansicht je Gerät" (Anforderung #5). Jede EGYM-Maschine loggt pro Training eine Kraft-Messung. activityId = stabile Geräte-ID (Katalog §4).

Endpoint Query Liefert
GET /mwa/api/measurements/v1.0/strength/latest-measurements zoneId, locale letzte Messung pro Gerät (Übersicht)
GET /mwa/api/measurements/v1.0/strength activityId, zoneId, startDate, endDate Roh-Messungen eines Geräts im Zeitraum
GET /mwa/api/measurements/v1.0/strength-measurements-yearly-summary activityId, shift, zoneId Monats-Aggregat über ein Jahr
GET /mwa/api/measurements/v1.0/strength-measurements-monthly-summary activityId, shift, zoneId Aggregat über einen Monat

Messung (strengthMeasurements[]):

{ "id":308609429, "createdAt":"2026-06-01T09:13:06.879Z", "timezone":"Europe/Berlin",
  "activity":{ "activityId":1011, "label":"EGYM Schulterpresse",
               "icons":["…/prod/icons/1011_0.jpeg","…/prod/icons/1011_1.jpeg"],
               "availableForManualTest":false },
  "strength":{ "value":95, "progress":"up", "percentageDiff":6.13, "amountDiff":5,
               "createdAt":"2026-06-01T09:13:02.838" },
  "strengthSet":{ "reps":1, "weight":95.333 },
  "source":"FITNESS_MACHINE", "bodyRegion":"UPPER", "sourceLabel":"Fitnessgerät" }

Verwandt: - GET /mwa/api/analysis/v1.0/muscle-imbalances?zoneId=… → Agonist/Antagonist-Paare mit position in optimalRange (z. B. BICEPS↔TRICEPS, LATS↔SHOULDER) — perfekte „Balance"-Visualisierung. Enthält agonistActivityId/antagonistActivityId (→ Geräte). - GET /challenges/api/v1.0/exercisers/{exerciserId}/machine-challenges?locale=de-DE 🟦 → machineType (M3, M5, M7, M8, M15…) ↔ machineLabel (dt.) ↔ exerciseCode. Brücke von der physischen Studio-Gerätenummer (M-Code) auf activityId.

3.4 Körperdaten / Waage = „Ground Truth" 🟩

Quelle source:"FITHUB" (EGYM Fitness Hub, InBody-Segmentwaage). Das ist die hochwertige Waage aus Anforderung #6 — priorisieren vor Garmin Index S2.

Endpoint Query Liefert
GET /mwa/api/measurements/v1.0/latest-body-metrics alle aktuellen Körperwerte (51 Typen, §5)
GET /mwa/api/measurements/v1.0/body-metrics types, granularity, startDate, endDate, zoneId Verlauf je Typ (Punkt-Reihe)
GET /mwa/api/measurements/v1.0/body-measurements-grouped types, zoneId, startDate, endDate je Mess-Session gruppiert (id, createdAt, metrics[])

body-metrics (Verlauf):

[ { "type":"WEIGHT_KG", "history":[ {"value":79.9,"date":"2026-06-01"},
                                     {"value":80.4,"date":"2026-05-01"} ] } ]

body-measurements-grouped (eine Messung = eine Waage-Sitzung):

{ "id":285708882, "createdAt":"2026-06-01T08:18:49", "source":"FITHUB",
  "metrics":[ {"type":"WEIGHT_KG","value":79.9,"valueInterpretation":"…"}, … ] }

3.5 Bio-Alter, Cardio, Flexibilität, Squat 🟩

Starke Motivations-Widgets („Feuerwerk", Anforderung): | Endpoint | Liefert | |---|---| | GET /mwa/api/analysis/v1.0/bio-age?locale=de-DE | Gesamt-/Metabol-/Flex-/Cardio-Bio-Alter + Quotes | | GET /mwa/api/analysis/v1.0/bio-age-yearly-summary?types=MUSCLE&types=METABOLIC&…&shift=0 | Bio-Alter-Verlauf (Jahr) je Typ | | GET /mwa/api/analysis/v1.0/bio-age-monthly-summary?types=…&shift=0 | dito (Monat) | | GET /mwa/api/measurements/v1.0/cardio?metricType=BLOOD_PRESSURE\|RESTING_HEART_RATE\|VO2MAX&zoneId&startDate&endDate | Blutdruck / Ruhepuls / VO2max | | GET /mwa/api/measurements/v1.0/cardio-measurements-yearly-summary?shift=0&zoneId | Cardio-Jahres-Summary | | GET /mwa/api/measurements/v1.0/flexibility-latest-metrics?zoneId&locale | Beweglichkeit je Gelenk (FITHUB) | | GET /mwa/api/measurements/v1.0/squat-latest-metrics?zoneId&locale | Kniebeuge-Analyse (Tiefe/Rücken/Schultern) | | GET /mwa/api/measurements/v1.0/squat-history?granularity=ONE_ITEM_PER_MONTH&startDate&endDate&locale&zoneId | Squat-Verlauf |

bio-age (Auszug): totalBioAge.value=35, metabolicAge=43 (mit bodyFat, bmi), flexibilityAge=43, cardioAge=31 (mit restingHeartRate, systolic/diastolicPressure, vo2Max, jeweils source: MANUAL/…). Jeder Wert trägt progress/percentageDiff/amountDiff.

3.6 Workouts (Trainingseinheiten, gemischt) 🟩

GET /mwa/api/workouts/v1.0/workouts?completedAfter=…&completedBefore=…&gymLocationId=…&measurementSystem=METRIC&locale=de-DE → Array von Einheiten; Item:

{ "completedAt":"2026-05-22T17:21:45", "timezone":"Europe/Berlin",
  "workoutPlanName":null,
  "exercises":[ {
    "id":5488740128653312, "label":"Fahrrad Outdoor",
    "activity":{ "activityId":429, "category":"CARDIO_OUTDOOR" },
    "sets":[ {"setType":"TIME_BASED","numberOfReps":null,"weight":null,
              "duration":2440,"distance":13575.26,"speed":5.56,"heartRate":null} ],
    "completedAt":"2026-05-22T17:21:45", "source":"APPLE_HEALTH",
    "sourceType":"CONNECTED_APP", "kiloCalories":225.0, "points":190,
    "summary":{ "totalDuration":2440, "totalDistance":13575.26, "avgSpeed":5.56 },
    "netpulseWorkoutId":"…" } ] }

3.7 Gamification / Punkte / Ranking 🟦

Endpoint Liefert
GET /analysis/api/v1.0/exercisers/{exerciserId}/activitylevels points, level (bronze/silver/gold), goal, daysLeft
GET /analysis/api/v1.0/exercisers/{exerciserId}/rankingwithleaderboard?languageCode=de&size=20 eigener Rang + Leaderboard (mit fremden Avataren!)
GET /np/np-rewards/v1/programAccount/{exerciserId} Punkte-Konto (currentPointsBalance)
GET /np/exerciser/{exerciserId}/goal · …/completedGoals aktuelles/erledigte Ziele
GET /np/exerciser/{exerciserId}/challenges?grouping=joinStatus · …/challenges/active Challenges
GET /feed/api/v1.0/gym-locations/{gymLocationId}/feed?startDate&endDate&locale Studio-Activity-Feed (fremde Nutzer)

⚠️ rankingwithleaderboard und feed enthalten Daten/Avatare anderer Mitglieder (Klarnamen abgekürzt, Profilbilder). Für unsere App: entweder weglassen oder nur den eigenen Rang/Punkte ziehen — fremde PII nicht persistieren (vgl. §7).

3.8 Feature-Flags & Sonstiges (für Doku-Vollständigkeit)


4. Geräte-Katalog (EGYM-Maschinen, activityId)

Stabile IDs aus measurements/strength. Icons liegen als Assets unter app/egym-assets/machine-icons/{activityId}-{slug}.jpeg (öffentlich geladen, §8). M-Codes (physische Studio-Nummer) aus machine-challenges, soweit gesehen. Deutsche Labels wie sie der strength-Endpoint mit locale=de-DE ausliefert (verifiziert beim Login-Capture 06.06.2026 Dennis-Account).

activityId Label (DE, aus API) Region M-Code Asset
994 EGYM Beinstrecker LOWER 994-leg-extension.jpeg
995 EGYM Bauchtrainer CORE 995-abdominal-crunch.jpeg
996 EGYM Rückentrainer CORE M3 996-back-extension.jpeg
997 EGYM Beinbeuger LOWER 997-leg-curl.jpeg
998 EGYM Brustpresse UPPER M5 998-chest-press.jpeg
999 EGYM Ruderzug UPPER 999-seated-row.jpeg
1000 EGYM Latzug UPPER M7 1000-lat-pulldown.jpeg
1001 EGYM Glutaeus CORE M8 1001-glutes.jpeg
1002 EGYM Beinpresse LOWER 1002-leg-press.jpeg
1003 EGYM Abduktor CORE 1003-abductor.jpeg
1004 EGYM Adduktor CORE 1004-adductor.jpeg
1005 EGYM Rotator CORE 1005-rotary-torso.jpeg
1007 EGYM Butterfly Reverse UPPER 1007-reverse-flys.jpeg
1008 EGYM Butterfly UPPER 1008-butterfly.jpeg
1009 EGYM Bizeps UPPER M15 1009-bicep-curl.jpeg
1010 EGYM Wadentrainer LOWER 1010-calf-press.jpeg
1011 EGYM Schulterpresse UPPER 1011-shoulder-press.jpeg
1012 EGYM Trizeps UPPER 1012-triceps.jpeg

1006 existiert im Studio nicht / wurde nicht trainiert. Restliche M-Codes beim nächsten Capture (Gameday-/Challenge-Screen vollständig öffnen). Englische Labels kommen mit locale=en-DE (z.B. „EGYM Leg extension") – wir bleiben bei DE.

⚠️ UI-Falle "letzte Messung": strength/latest-measurements zeigt das letzte je Gerät – auch wenn es Jahre her ist (Dennis-Sample: 6 Geräte vom 01.06.2026, 12 noch aus Mai 2025!). Im Screen muss „zuletzt vor X" sichtbar werden, sonst wirkt der Score veraltet/falsch.


5. Körperdaten-Vokabular (51 Metrik-Typen aus latest-body-metrics)

Alle source:"FITHUB". Basis-Typ = der Wert; _LOW/_TOP-Varianten = gesunde Spannbreite (Band um den Wert, für „Sollbereich"-Visualisierung). Beispiel Dennis 01.06.2026:

Primär (für unsere App): | Typ | Wert | Bedeutung | |---|---|---| | WEIGHT_KG | 79.9 | Gewicht (Ground Truth) | | BMI | 27.01 | BMI | | BODY_FAT_PERCENTS | 22.4 | Körperfett % | | BODY_FAT_MASS_KG | 17.9 | Körperfett (kg) | | SKELETAL_MUSCLE_MASS_KG | 35.3 | Skelettmuskelmasse | | FAT_FREE_MASS_KG | 62.0 | fettfreie Masse | | BODY_WATER_LITER | 45.3 | Körperwasser (Liter) | | BASAL_METABOLIC_RATE_KJ | 7155 | Grundumsatz (kJ) | | VISCERAL_FAT_AREA_CM2 | 75.7 | viszerales Fett (Fläche) | | VISCERAL_FAT_LEVEL_CM2 | 7.0 | viszerales Fett (Level) | | BODY_PHASE_ANGLE | 6.9 | Phasenwinkel (Zellgesundheit) | | ECW_TBW_PERCENT | 36.8 | Wasserverteilung (Ödem-Indikator) | | HEIGHT_CM | 172 | Größe |

⚠️ Knochenmasse (Anforderung #2) war in diesem InBody-Datensatz nicht als eigener Typ dabei (InBody liefert oft BONE_MINERAL_CONTENT separat). Garmin Index S2 liefert Knochenmasse → ggf. von dort ergänzen (Garmin als Sekundärquelle).

Segmental (je Gliedmaße, für Körper-Heatmap): SEGMENTAL_LEAN_{RIGHT,LEFT}_{ARM,LEG}_KG, SEGMENTAL_LEAN_TRUNK_KG, SEGMENTAL_FAT_{…}_KG — plus _TOP/_LOW-Bänder. Wasser-Detail: INTRA_CELLULAR_WATER_LITER, EXTRA_CELLULAR_WATER_LITER (+ Bänder). valueInterpretation: …_EXCELLENT / _NORMAL / _ELEVATED / _UNSPECIFIED → Ampelfarbe.


6. Mapping auf unser Datenmodell (Skizze für den „Aktivitäten"-Screen)

Anforderung: „Besuche als Liste; ein Besuch = Liste von Geräten; Klick auf Gerät → Besuchs-Detail + Verlauf + Liste der Besuche, bei denen das Gerät genutzt wurde" — das ist exakt die Artikel↔Kassenbon-Beziehung aus der bestehenden App.

egym_account (pro Silverscale-Profil)
  exerciser_id, egym_account_id, membership_id, gym_location_id, tokens…

egym_visit            (= „Bon")            ← Cluster aus check-in + Messungen eines Tages
  id, profile_id, visited_at, source(checkin|derived), duration?, kcal?, points?

egym_machine          (= „Artikel", Stammdaten)  ← Katalog §4
  activity_id (PK), label_de, body_region, m_code?, icon_path

egym_strength_set     (= „Bon-Position")   ← measurements/strength
  id (eGYM id), visit_id, activity_id, created_at,
  strength_value, weight_kg, reps, progress, percentage_diff, body_region

egym_body_measurement (= Waage-Sitzung)    ← body-measurements-grouped
  id (eGYM id), profile_id, measured_at, source='FITHUB'
egym_body_metric
  measurement_id, type, value, interpretation

7. Datenschutz (WICHTIG)


8. Assets

app/egym-assets/machine-icons/18 EGYM-Geräte-Icons (500×500 JPEG), direkt von public-measurements-prod-egym-com.storage.googleapis.com/prod/icons/{activityId}_0.jpeg (öffentlich, keine PII). Dateiname = {activityId}-{slug}.jpeg (Mapping §4). _0 und _1 waren je identisch → nur eine Variante gespeichert.

Nicht abgelegt: fremde Mitglieder-Avatare (DSGVO, §7); eigener Avatar + Widget-PNGs kamen im Capture als 304/0 Byte (gecacht). Bei Bedarf nachladbar: - eigener Avatar: https://galaxy.cdn.egym.com/avatar-scaled/{exerciserId}.jpg - Übungs-Bibliothek (Bilder/Videos): storage.googleapis.com/activity-library-prod-prod-co/images/activities/processed/egym/{exerciseCode}/…


9. Reproduktion des Captures

# Mac:
mitmweb --listen-port 8080 --set http2=false --allow-hosts '(netpulse\.com|egym\.com)'
# iPhone: WLAN-Proxy → Mac-IP:8080; mitm.it-Cert installieren UND unter
#   Info → Zertifikatsvertrauen freischalten (sonst TLS-Fail für alles).
# --set http2=false   : Netpulse-Header hat Trailing-Whitespace → HTTP/2 bricht sonst ab.
# --allow-hosts       : nur eGYM/Netpulse abfangen → iCloud etc. ungestört, kein Log-Müll.

# Auswertung (Wegwerf-venv mit mitmproxy 12):
python3 -m venv /tmp/mitmtool && /tmp/mitmtool/bin/pip install mitmproxy
/tmp/mitmtool/bin/python -c 'from mitmproxy.io import FlowReader; …'   # Flows lesen

Flow-Dump-Format ist kein HAR, sondern mitmproxys natives tnetstring-Format (Bodies gzip) → mit mitmproxy.io.FlowReader lesen; flow.response.get_text() dekomprimiert.


10. Offene Punkte / nächster Capture

  1. Login + Token-Mint ERLEDIGT (Capture egym-login, §2): Netpulse-Login (POST /np/exerciser/login → JSESSIONID) und MWA-Bearer aus tokens/FLS (byte-identisch verifiziert). Kein Refresh-Token nötig. - Verbleibend: JSESSIONID-Lebensdauer unbekannt (Java-Session — kann kurz sein). Empirisch ermitteln bzw. Re-Login bei 401 einbauen. accessTokenExpiresAt der FLS-Antwort als Vorlauf für proaktiven Token-Refresh nutzen.
  2. Verlaufs-Granularität des 30-Tage-Toggles bestätigen (vermutlich ONE_ITEM_PER_DAY).
  3. Restliche M-Codes (physische Gerätenummern) im Gameday/Challenge-Screen sammeln.
  4. Knochenmasse: prüfen, ob InBody das als eigenen Typ liefert (anderer Mess-Screen) oder ob wir es aus Garmin ziehen.
  5. Garmin-Strang separat (inoffizielle Library garth/garminconnect, kein MITM) — eigenes Dokument, sobald wir dort sind.