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>
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": ... }. errorsisnullon 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-IDrequest header on API calls (not required by the server, per the header table above, but the app always sends it).clientApplicationInfo.whiteLabelIdin the login body, paired withtype: whitelabel. PerfectGym Go also has auniversalmode (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/WhoIsInreturns 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×tamp=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 bothfalse.instructorId→Instructors/Instructors;personalTrainingTypeId→PersonalTrainings/PersonalTrainingsTypes;clubId→ the club list. Note the booking's ownnamedoes not match the type'sname.
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 tagId ↔
classTypeId.
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 instructorId ↔
clubId. 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— joinproductId↔productCategoryId.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.