Files
hass-west-wood/api.md
T
jackos1998 3e6ef7a908 Expand PT-booking docs in api.md
Flesh out `PersonalTrainings/Bookings` (past+future in one list, client-side
`startDate` filtering, per-field notes, `timestamp` delta-sync) and the
`PersonalTrainingsTypes` / `Instructors/Instructors` enrichment endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:06:37 +01:00

18 KiB

West Wood Club API

The West Wood Club Android app is a white-label build of PerfectGym Go. It talks to PerfectGym's hosted backend. Details below were reverse-engineered from android-flows.mitm (app version 1.28.3).

Caveat: this document was written by an AI agent from a single traffic capture and a decompiled APK. It reflects what was observed, not official docs — treat field meanings, requirements, and especially inferred behaviour as best-effort and verify before relying on anything.

Base

  • Base URL: https://goapi2.perfectgym.com
  • All responses are wrapped as { "data": ..., "errors": ... }.
  • errors is null on success.

Common request headers

The app sends the headers below on every request, but most are not required. Testing GET /v1/Clubs/Clubs with only an Authorization header (no X-Go-* headers and no app User-Agent) returned 200 with the full response. So for an authenticated request, only Authorization is needed; the X-Go-* headers and the app-specific User-Agent can be omitted.

Header Value Required?
Authorization bearer <token> Yes (authenticated endpoints)
Accept application/json No
Content-Type application/json No (GET); set it for POST bodies
Accept-Language en No
X-Go-App-Platform Android No
X-Go-App-Version 1.28.3 No
X-Go-White-Label-ID 7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc No
User-Agent West Wood Club/1.28.3.0 (com.perfectgym.perfectgymgo2.westwoodclub; build:1028003000; Android 16) No

Note: login is different — the white-label ID is passed in the request body there (see below), not as a header.


Login

POST /v1/Authorize/LogInWithEmail

Unauthenticated. Returns a bearer token used for all subsequent requests.

Request body:

{
  "email": "user@example.com",
  "password": "<password>",
  "clientApplicationInfo": {
    "type": "whitelabel",
    "whiteLabelId": "7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc"
  }
}

Response:

{
  "data": {
    "token": "<uuid>_<uuid>",
    "action": "LogIn",
    "expireTime": null,
    "isEmailConfirmed": true,
    "tokenType": "bearer",
    "authorizationHeader": "bearer <token>"
  },
  "errors": null
}

Use data.authorizationHeader verbatim as the Authorization header on subsequent calls (i.e. bearer + data.token).

The token most likely does not expire. expireTime was null in the capture, the login response carries no refresh token, and the app appears not to store any credentials to silently re-login — it seems to just hold the one bearer token. So treating it as long-lived is reasonable. This is an inference, not a guarantee: the backend has TokenExpired / InvalidToken error codes, so it can still invalidate a token server-side. Handle a 401/403 by obtaining a fresh token.


White-label ID

7d073db5-0ef8-4d78-89ec-4a8bebaf4cbc identifies the West Wood tenant. It is not a secret — it appears in deep-link URLs — and is hardcoded into the app binary (confirmed by decompiling the APK: it is a string literal in the smali, not in resources/assets or fetched at runtime). A different white-label brand is a different build with a different UUID.

The app uses it three ways:

  • X-Go-White-Label-ID request header on API calls (not required by the server, per the header table above, but the app always sends it).
  • clientApplicationInfo.whiteLabelId in the login body, paired with type: whitelabel. PerfectGym Go also has a universal mode (the generic multi-tenant app) that omits the white-label ID; white-label builds pin one tenant via this UUID.
  • pgg-<uuid> in the in-app web-flow deep links (e.g. https://goapi2.perfectgym.com/contract/purchase/pgg-7d073db5-...).

It corresponds to companyId 251 ("West Wood Club") server-side: the UUID is the public tenant key, companyId the internal numeric id (see Other endpoints). GET /v1/Companies/Companies lists every tenant on the platform (the universal-mode operator list).


Club list

GET /v1/Clubs/Clubs?timestamp=0

Authenticated. Lists all clubs for the tenant. The timestamp=0 query param requests the full list (the API supports incremental sync — timestamp is a per-record version, see the timestamp field on each item).

Response (truncated):

{
  "data": [
    {
      "companyId": 251,
      "name": "West Wood Club Clontarf",
      "description": null,
      "longitude": -6.228,
      "latitude": 53.3634,
      "address": {
        "country": "Ireland",
        "city": "Dublin",
        "postalCode": "D03 T6T3",
        "line1": "Clontarf Road, Dublin 3",
        "line2": ""
      },
      "isHidden": false,
      "qrCodeSuffixConfig": "EndsWithWindowsNewLine",
      "id": 960,
      "timestamp": 1692837739,
      "isDeleted": false
    }
  ],
  "errors": null
}

Key field: id is the club ID referenced by other endpoints (e.g. WhoIsInCount). Known club IDs from the capture: 959, 960, 961, 962, 963, 964.


Current members (live occupancy)

GET /v1/Clubs/WhoIsInCount

Authenticated. Returns the number of members currently checked in at each club — the live occupancy count. This is the primary signal for an occupancy sensor.

Response:

{
  "data": [
    { "count": 253, "clubId": 959 },
    { "count": 294, "clubId": 960 },
    { "count": 122, "clubId": 961 },
    { "count": 59,  "clubId": 962 },
    { "count": 96,  "clubId": 963 },
    { "count": 70,  "clubId": 964 }
  ],
  "errors": null
}

clubId maps to id from the club list above.

Note: a related endpoint GET /v1/Classes/WhoIsIn returns the named list of members booked into classes (first/last name, classId). That is per-class booking data, not live building occupancy.


Other endpoints

Every other endpoint seen in the capture is catalogued below, grouped by area. Personal data, IDs, and amounts are anonymised. Samples show a single representative data[] item (the wrapper and timestamp/isDeleted fields are omitted for brevity). "Empty in capture" means the endpoint returned data: [] for this account, so the item shape is unknown.

Most are GET, authenticated with the bearer header, and return the standard { "data": [...], "errors": null } wrapper. Many take timestamp=0 (full list) and some take companyId.

companyId is the PerfectGym tenant (the gym operator), not an individual gym — 251 is West Wood Club (from GET /v1/Companies/Companies, which lists every operator on the platform). It's effectively a constant here. An individual gym is a clubId (= id in Clubs/Clubs); every record carries both.

Opening hours

GET /v1/Clubs/OpeningHours?companyId=251&timestamp=0

Per-club weekly hours — one row per club per dayOfWeekOrHoliday. Good for an "open now" binary sensor. OpeningHoursExceptions (same params) holds holiday overrides and was empty in the capture.

{
  "clubId": 959,
  "dayOfWeekOrHoliday": "Monday",
  "isClosed": false,
  "openFrom": "06:00",
  "openUntil": "23:00",
  "openTwentyFourSeven": false,
  "isOpenTwentyFourHours": false,
  "companyId": 251,
  "id": 5323
}

Personal training bookings

GET /v1/PersonalTrainings/Bookings?timestamp=0

The account's own PT sessions — past and future in one list, newest-booked last. The endpoint does not filter by date; to surface upcoming bookings, filter client-side on startDate > now (and typically not isCanceled / not isCompleted). Classes/BookingsV2 (same shape, for class bookings) was empty in the capture.

{
  "name": "New - 4th Program- Review",
  "startDate": "2026-05-26T08:30:00+01:00",
  "endDate": "2026-05-26T09:00:00+01:00",
  "isCanceled": false,
  "isCompleted": true,
  "instructorId": 0,
  "clubId": 962,
  "personalTrainingTypeId": 0,
  "remoteAccountId": 0,
  "companyId": 251,
  "id": 0
}

Fields:

  • name — already human-readable and self-contained (e.g. "New - 1st Consultation", "New - 4th Program- Review"). A "next PT booking" sensor needs only this endpoint — the lookups below are enrichment.
  • startDate / endDate — ISO-8601 with offset (+01:00); the duration is implied (no separate field on the booking).
  • isCanceled / isCompleted — booleans. A future session has both false.
  • instructorIdInstructors/Instructors; personalTrainingTypeIdPersonalTrainings/PersonalTrainingsTypes; clubId → the club list. Note the booking's own name does not match the type's name.

Delta sync: like other catalogue endpoints, passing the largest timestamp seen in a previous response (instead of 0) returns only rows changed since — data: [] when nothing changed. A simple poller can ignore this and always send timestamp=0 to get the full list each time.

Membership contract

GET /v1/RemoteAccounts/Contracts?timestamp=0

The account's membership contract(s). status (e.g. Current), startDate, cancelDate, endDate — useful for a membership-status sensor.

{
  "status": "Current",
  "startDate": "2026-04-20T00:00:00+00:00",
  "cancelDate": null,
  "endDate": null,
  "paymentPlanId": 0,
  "accountId": 0,
  "remoteAccountId": 0,
  "companyId": 251,
  "id": 0
}

Upcoming charges

GET /v1/RemoteAccounts/ContractsCharges?timestamp=0

Scheduled membership charges — dueDate + amountGross/toPay (value + currencyIso). Useful for a "next payment" sensor.

{
  "dueDate": "2026-07-01T00:00:00+00:00",
  "amountGross": { "value": "0.0000", "currencyIso": "EUR" },
  "toPay": { "value": "0.0000", "currencyIso": "EUR" },
  "description": "<plan name> (31 days) in 2026-07",
  "type": "Membership",
  "contractId": 0,
  "accountId": 0,
  "companyId": 251,
  "id": 0
}

Perfect Score

GET /v1/PerfectScore/PerfectScore

A single gamification points value for the account. PerfectScoreLevels lists the level thresholds. Goals/GoalsProgresses (goal tracking) was empty in the capture.

{ "data": [ { "points": 175 } ], "errors": null }

GET /v1/PerfectScore/PerfectScoreLevels — the level ladder (type is a colour band) with promotion/demotion dates per level:

{ "type": "Green", "points": 0, "promotionDate": "2026-04-20T00:00:00+00:00", "demotionDate": null, "id": 0 }

Account & profile

GET /v1/Accounts/Account?timestamp=0 — the signed-in user's profile (PII).

{
  "email": "user@example.com",
  "isEmailConfirmed": true,
  "firstName": "<first>",
  "lastName": "<last>",
  "nickName": null,
  "birthdate": "1990-01-01",
  "phoneNumber": "<phone>",
  "gender": "Male",
  "photoUrl": null,
  "instagramUrl": null,
  "id": 0
}

GET /v1/Accounts/AccountNotificationsSettings?timestamp=0 — per-channel notification toggles (isClubNotificationsActive, isBookingsNotificationsActive, isClassReminderActive, is{Sms,Email,Push}NotificationsChannelActive, minutesBeforeClassReminderConfiguration, …).

GET /v1/Accounts/AccountPrivacySettings?timestamp=0 — leaderboard/booking visibility flags (showUserClassBookings, showUserOnPerfectScoreLeaderboard, showUserOnClubGamesLeaderboards).

GET /v1/Accounts/FamilyMembers?timestamp=0 — linked family members. Empty in capture.

Remote accounts (membership identity)

A "remote account" links the app user to a membership at a company. Most membership endpoints key off remoteAccountId.

GET /v1/RemoteAccounts/Accounts?timestamp=0:

{
  "accountId": 0,
  "companyId": 251,
  "remoteId": 0,
  "homeClubId": 962,
  "isSelected": true,
  "businessNumber": "<membership-no>",
  "id": 0
}

GET /v1/RemoteAccounts/PaymentPlans?timestamp=0 — membership plan definitions (name, priceGross, commitmentPeriodMonths, paymentIntervalMonths). Amount redacted:

{
  "name": "<plan name>",
  "priceGross": { "value": "0.0000", "currencyIso": "EUR" },
  "commitmentPeriodMonths": 24,
  "paymentIntervalMonths": 1,
  "companyId": 251,
  "id": 0
}

Classes catalogue

GET /v1/Classes/Classes?timestamp=0 — scheduled class instances (the timetable):

{
  "startDate": "2026-05-26T10:45:00+01:00",
  "endDate": "2026-05-26T11:15:00+01:00",
  "attendeesCount": 0,
  "attendeesLimit": null,
  "standbyListLimit": 0,
  "isReservationRequired": true,
  "isStreamingAvailable": false,
  "clubZone": "Main gym floor",
  "instructorId": 0,
  "classTypeId": 20193,
  "clubId": 960,
  "companyId": 251,
  "id": 0
}

GET /v1/Classes/ClassesTypes?timestamp=0 — class-type catalogue (name, description, photoUrl, isAvailableInMobileApp). classTypeId on a class points here.

GET /v1/Classes/Tags?timestamp=0 — the tag vocabulary (type, name, photoUrl), e.g. Strength, Yoga, Cardio.

GET /v1/Classes/ClassesTypesTags?timestamp=0 — many-to-many join of tagIdclassTypeId.

GET /v1/Classes/ClassesTypesRatingSummaries?timestamp=0 — aggregate rating + ratingsCount per classTypeId.

GET /v1/Classes/ClassesRatings, GET /v1/Classes/ClassesVisits, GET /v1/Classes/Favourites — per-account ratings, visit history, and favourited classes. All empty in capture.

Instructors

GET /v1/Instructors/Instructors?timestamp=0 — instructor directory. Large list (hundreds of rows), mostly isActive: false and/or isDeleted: true legacy staff; position is a department label (Swim, Tennis, Sales, …). Only needed to resolve a booking's instructorId to a name.

{
  "firstName": "<first>",
  "lastName": "<last>",
  "displayName": "<name>",
  "position": "Swim",
  "sex": "Female",
  "isActive": false,
  "photoUrl": null,
  "description": null,
  "companyId": 251,
  "id": 0,
  "isDeleted": false
}

GET /v1/Instructors/InstructorsClubs?timestamp=0 — join of instructorIdclubId. GET /v1/Instructors/Favourites — favourited instructors (empty in capture).

GET /v1/PersonalTrainings/PersonalTrainingsTypes?timestamp=0 — PT session-type catalogue; personalTrainingTypeId on a PT booking points here. This is where the session duration lives (the booking itself carries only start/end). name ranges over paid sessions ("60 min PT session", "45 min PT session FREE"), consultations/reviews, and non-session blocks ("Lunch 30 min", "Shower 15 min"). Many rows are isDeleted: true legacy types.

{
  "name": "60 min PT session",
  "duration": "01:00",
  "productId": 105674,
  "companyId": 251,
  "id": 0,
  "isDeleted": false
}

Products & pricing

GET /v1/Products/Products?timestamp=0 — purchasable products/services:

{
  "name": "Squash 1 hour",
  "description": "",
  "type": "Service",
  "availableFor": "Everyone",
  "defaultPriceGross": { "value": "50.00", "currencyIso": "EUR" },
  "validityPeriodInSeconds": null,
  "isAvailable": true,
  "isVisibleForSale": true,
  "companyId": 251,
  "id": 0
}
  • GET /v1/Products/ProductsCategories?timestamp=0 — category tree (name, order, parentCategoryId).
  • GET /v1/Products/ProductsProductsCategories?timestamp=0 — join productIdproductCategoryId.
  • GET /v1/Products/ProductsClubs?timestamp=0 — per-club price overrides (priceGross, productId, clubId).
  • GET /v1/Products/AccountProducts?timestamp=0 — products the account owns (quantity.{initialQuantity,currentQuantity}, purchaseDateUtc, expireDateUtc).
  • GET /v1/Products/DiscountedProductPrice?... — computed price for a product: { "productId": 0, "clubId": 962, "gross": 3.0, "net": 2.44, "vat": 0.56 }.
  • GET /v1/Products/ProductsPaymentsPlans — empty in capture.

Engagement & timeline

GET /v1/Timeline/Timeline?timestamp=0 — the account's activity feed:

{ "accountId": 0, "activityType": "ClubVisit", "trackingServiceId": 16037, "startDate": "2026-04-20T09:31:58+00:00", "id": 0 }

GET /v1/Timeline/TimelineElementsDetails?timestamp=0 — key/value detail rows for a timeline element (timelineElementId, type, value, valueType), e.g. type: "ClubName", value: "West Wood Club Dun Laoghaire".

GET /v1/Referrals/ReferralRule?timestamp=0 — referral programme copy (title, description). GET /v1/Referrals/ReferralsPrizes, GET /v1/Campaigns/Banners, GET /v1/PushNotifications/Notifications, GET /v1/Goals/Goals — empty in capture.

Fitness tracking (third-party)

These drive connections to external wearables/services — the OAuth flows behind the refreshToken/oauthToken classes in the app, unrelated to the PerfectGym session.

GET /v1/TrackingServices/Services?timestamp=0 — available services and their OAuth config:

{
  "type": "Fitbit",
  "description": "The most popular fitness wearable",
  "connectionDetails": {
    "authMethod": "OAuth2",
    "authUrl": "https://www.fitbit.com/oauth2/authorize?client_id=...&redirect_uri=pgg://fitbit.callback&scope=activity%20profile%20weight",
    "redirectUrl": "pgg://fitbit.callback"
  },
  "color": "#00B0B8",
  "iconUrl": "https://.../fitbit_icon.png",
  "id": 0
}
  • GET /v1/TrackingServices/ServicesActivities?timestamp=0 — activity types per service (activityType, trackingServiceId).
  • GET /v1/TrackingServices/ServicesActivitiesConfigurations?timestamp=0 — per-account on/off per activity (trackingServiceActivityId, isTurnedOn).
  • GET /v1/TrackingServices/ServicesConnections — the account's active connections. Empty in capture.

Settings, auth & lifecycle

GET /v1/FeaturesSettings/FeaturesSettings?timestamp=0 — per-tenant feature flags; worth checking before assuming a feature works:

{ "featureName": "ClubWhoIsIn", "isAvailable": true, "companyId": 251, "id": 0 }

Observed flags include MobileCheckIn, Classes, ClubWhoIsIn, Ratings, PersonalTrainings, FacilityBooking, Goals, PerfectScore, Instructors (available) and FamilyBooking, ContractPayments, ProductPayments, Courses (unavailable).

GET /v1/Clubs/Contacts, Clubs/Equipment, Clubs/Photos, Clubs/Urls, Clubs/Favourites (all companyId+timestamp) — per-club detail lists, all empty in capture.

GET /v1/Authorize/OnlineJoining — returns { "onlineJoiningUrl": null } (sign-up web flow, disabled here).

POST /v1/Authorize/VerifyEmail — pre-login check; returns { "action": "LogIn" } (vs a sign-up action) to decide whether an email already has an account.

POST /v1/ApplicationLifetime/ApplicationStarted and POST /v1/ApplicationLifetime/ApplicationNeedsRefreshedUserData — telemetry/sync pings; both return null data. Not needed by the integration.