For the complete documentation index, see llms.txt. This page is also available as Markdown.

GymMaster — ontology map

What GymMaster gives Kula Intelligence, what it cannot give, and how its members, classes, attendance, memberships and door access map into the canonical model.

Source id: com.gymmaster · Role: booking + membership system of record · Access: Member Portal API (two API keys) + a door-access webhook

GymMaster is the studio's operational system: members, clubs, staff, the class schedule, class attendance, memberships, and — from the day the connector goes live — door access. It is not a payments system: there is no API for money at all.

Two limits drive most wrong answers here, and both are about time:

  • Door access starts the day we connect. It arrives by webhook, so there is no history before go-live.

  • Class history is available, but attendee names are frozen. A roster records the member's name as it was on the class date, so a later name change breaks the link permanently.

Coverage at a glance

GymMaster entity
Canonical home
Grain
History
Freshness

companies (clubs)

people.location (kind = 'club')

one row per club

full snapshot

each poll

door / area access points

people.location (kind = 'access_point')

one row per door

derived from access data

materialised after load

salesrep (staff)

people.staff

one row per staff member

full snapshot

each poll

members

people.member

one row per member

full snapshot, modified-since filtered

each poll

memberships (catalogue)

commerce.plan

one row per membership type

full snapshot

each poll

booking/classes/schedule

bookings.class_session

one row per scheduled class

historical — 7-day windows, iterable back

each poll

per-class attendees

bookings.attendance

one row per attendee per class

matches the class windows pulled

each poll

door access (webhook)

bookings.facility_entry

one row per access event

from go-live only

live

per-member member_memberships

people.member.plan_name / current_plan_id + a commerce.plan stub

current plan only

current state

each poll

per-member profile

people.member (fill-only enrich)

one row per member

current state

each poll

per-member visits/daily

lands raw only, not yet mapped

daily counts

Anything not in that table, we don't have.

Door access and the location tree

people.location holds two kinds of row for a GymMaster studio:

  • Clubskind = 'club', straight from the companies pull.

  • Doors and areaskind = 'access_point', with parent_location_id pointing at the club's source_external_id.

The access-point rows are derived from bookings.facility_entry, not pulled from GymMaster: each distinct door seen in the access log becomes a child location under its club (turnstile, 24hr door, hot studio, pilates door, and so on). So door/area detail rolls up to the club while keeping area-level granularity.

Because they're derived, a door only exists as a location once it has appeared in the access log. A door installed but never used won't be there.

Each bookings.facility_entry row carries door_id, door_name, status (granted / denied), reason, entry_method, access_category (door_access, class_attendance, sale, appointment) and access_type (entry, exit, checkin, purchase) — so it is the full member access log, not only door swipes.

What we cannot get

Each item says why. Most of these are hard limits of GymMaster, not gaps in our implementation.

Member status history before we started capturing it. GymMaster reports a member's current status only — there is no change log, so nothing recovers the moment a member went on hold or lapsed before we were watching. Kula synthesises the change by detecting it itself, so from the date capture began every status, membership-type, plan and suspension change is recorded in people.member_status_event — read it with list_member_status_changes, or the status_history block on get_member_context. Each member also carries one is_baseline = true origin row recording what we first saw; that is an observation, not a change, so filter it out before counting anything. Establish the capture start before charting a series:

Door access before we connected. GymMaster delivers access records by webhook only — there is no API to read history back. We capture every event from the moment the connector goes live, and nothing before that point exists. This is a permanent hole, not a backfill waiting to be run.

Always establish the start date before charting anything door-related:

A "walk-ins per month" chart that runs earlier than capture_started will show zeros that look like a collapse in traffic. Start the series at the capture date and say so.

Transactions, payments and revenue. GymMaster has no API for financial data. Not sales, not payments, not invoices, not outstanding balances. commerce.sale and commerce.payment are empty for a GymMaster studio. Revenue questions must be answered from a connected payments source (Stripe) or not at all.

Appointments and 1:1 sessions. GymMaster does not do appointments — there is no such concept in the product. bookings.appointment is empty, and always will be.

Anything about inactive or lapsed members beyond their member row. GymMaster only mints the per-member portal token used for visits, profile and member_memberships for members holding a current membership (Current, Active, Concession Pack). Members who are Expired, Hold or cancelled appear in the member list — you get the person and their status — but we cannot read their memberships, their visit history, or their profile detail. So:

  • Historical membership timelines don't exist. There is no "which plan were they on in March" for anyone.

  • Cancellation reasons, end dates and hold periods aren't available.

  • A lapsed member's plan_name is their last known plan, not evidence they still hold it. Their status is the authority on whether they're current.

A member's history once they're deactivated at the source. GymMaster drops a deactivated member's detail on its own side. Anything not already imported before deactivation is gone permanently — from GymMaster, and therefore from us.

Granular per-member visit records. The per-member visits/daily endpoint returns daily counts, not individual visits. Those rows land raw but have no canonical home yet (not built — the mapping is undecided). Granular presence comes from bookings.attendance (class bookings) and bookings.facility_entry (access events), not from here.

Class categories. GymMaster has no category concept at all. We derive class_session.class_category from keywords in the class name (so "Reformer Pilates" buckets as Pilates). It is a derived field — a helpful grouping, not vendor truth.

Bulk pagination. The members, companies, salesrep and plans lists are snapshots with no offset or page parameter — GymMaster silently ignores an offset and returns the same array forever. Completeness relies on the modified-since window keeping each pull under the vendor's undocumented response cap. The ingestor warns loudly when a pull comes back near that size; a very large single pull is a signal to narrow the window rather than trust the result.

Class and attendance history — available, with one catch

Unlike door access, class history is readable. The schedule endpoint returns a 7-day window per call, and those windows can be iterated backwards, so a studio's past classes and the members who attended them can be loaded well before the connection date.

The catch is how attendance is recorded.

Attendee names are as-at the class date

GymMaster's per-class attendees endpoint returns names only — there is no member id on an attendee — and the name it returns for a historical class is the name the member had at that time.

We hash the normalised name into a deterministic placeholder, unresolved-<hash>, written to bookings.attendance.member_id, with the real name carried in extras.member_name. The identity layer reconciles that placeholder to the real people.member row when the name matches.

So if a member's name is later changed on their member record — marriage, a correction, a preferred name — their historical attendance will not link. The roster still says the old name; the member record says the new one; nothing joins them automatically. The attendance rows are not lost, they just stay unresolved and drop out of any per-member analysis.

Consequences to account for:

  • Joining attendance to members will miss rows. An attendance row whose member_id still starts with unresolved- has not been matched. Some proportion of unmatched rows is normal and expected, and it skews older — the further back you go, the more name drift has accumulated.

  • Homonyms under-count. Two distinct members with the same normalised name attending the same class collapse into one attendance row. This is inherent to a names-only feed and is not corrected at ingest.

  • A roster corrected in GymMaster would otherwise strand the old hash forever. A per-class roster sync deletes attendance rows for a session that are no longer on the vendor's current roster — so attendance for a session reflects the roster as of the last poll, not an append-only log.

Quantify it before quoting per-member attendance figures:

No-show and late-cancel are not available. Presence in the attendee list is taken as attended; GymMaster's attendee feed doesn't distinguish booked-but-didn't-show, so booking_status is always attended.

Staff names arrive reversed

The class schedule renders the instructor as "Surname, Firstname" (often just "S, Amy"). We split and normalise it into a natural First Last display name. If you see a reversed or single-letter surname on a staff row that came only from the schedule, that's the vendor shape showing through — the direct salesrep pull is richer and wins.

Identity and join keys

Thing
GymMaster id
Canonical

Member

numeric member id

people.member.source_external_id

Staff

id or staffid

people.staff.source_external_id

Club

id or companyid

people.location.source_external_id (kind = 'club')

Door / area

door id from the access log

people.location.source_external_id (kind = 'access_point')

Class session

booking id

bookings.class_session.source_external_id

Membership type

id / membershiptypeid

commerce.plan.source_external_id

Class attendee

name only — no id

bookings.attendance.member_id as unresolved-<hash>

Door access rows carry a real member reference, so bookings.facility_entry joins to members cleanly — it is the more reliable of the two presence signals where both cover the same period.

Counting traps

Two presence signals with different start dates. bookings.attendance goes back as far as the class windows pulled; bookings.facility_entry starts at go-live. Any chart combining them will have a step change at the capture date that is an artefact, not a behaviour change. Pick one signal per question, or explicitly window both to the overlap.

Don't double-count a class visit. A member attending a booked class can appear in both tables — as attendance, and as an access event with access_category = 'class_attendance'. Filter facility entries to access_category = 'door_access' when you mean "came in without a class booking".

plan_name never gets cleared. The membership refresh uses COALESCE semantics deliberately: it fills a member's current plan but never blanks it. A lapsed member keeps their last-known plan name. Their status field is the authority on whether they're current — not the presence of a plan.

Class category is derived, and older rows may be NULL. Sessions ingested before the category rule shipped have class_category = NULL. Grouping by category silently drops them.

Class coverage equals windows run. A gap in bookings.class_session usually means an unrun 7-day window, not a cancelled week — and that gap is also a gap in attendance.

Read the guarded views. bookings.attendance_guarded and bookings.class_session_guarded mask restricted columns by the caller's scope and apply the live-wins filter where a studio also carries rows with source_is_backfill = true. Prefer them to the base tables.

Questions this source can and can't answer

Can answer well

  • Who are my members, what status are they, which club are they at

  • Class schedule, capacity, spots booked, instructor per session — including historically

  • Who attended a class (by name, with the matching caveats above)

  • Attendance frequency and gaps per member → at-risk / lapsing members

  • Who came in without booking a class — from the door-access capture date onward

  • Traffic by door, by area, by club; denied-access events and their reasons

  • Which membership types exist and their prices

  • Which plan a current member is on right now

Cannot answer from GymMaster alone

  • Revenue, takings, payments, refunds, outstanding balances (no API)

  • Door access, walk-ins or access-denied events before the connector went live (webhook-only; no history at the vendor)

  • No-show rate, late-cancel rate (the attendee list carries no status)

  • 1:1 appointments or PT sessions (GymMaster doesn't do them)

  • When a member's membership started, ended, or was put on hold (per-member memberships are current-only, and unreachable for non-current members)

  • Membership history, plan changes, upgrade/downgrade paths (same)

  • Per-member attendance for anyone whose name changed after the class (rosters store the name as-at the class date)

  • Anything about a member deactivated in GymMaster before their first import (destroyed at source)

  • Lifetime value or spend per member (no money data)

Phrase a gap as the vendor's limit and the capture window, not as missing data. "GymMaster's API doesn't expose door access before we started capturing it, so I can only tell you about door access since we went live on date" is correct and useful. "You have no door access data" is misleading, and so is silently starting the chart at zero.

Recipes — what works well

Reach for the tool before the SQL. Each of these encodes rules that hand-written SQL routinely gets wrong on GymMaster data — pause-awareness, the guarded views, the unresolved-name problem, studio-local time.

Who's lapsing → list_at_risk_members

The single highest-value call on a GymMaster studio. It buckets active members by days since last visit (7–13, 14–20, 21–27, 28+), and a "visit" is an attended class or a facility entry — so once door capture is running, walk-in-only members stop looking lapsed. It also excludes members on a pause, anchors the day count at the pause end for those recently back, and drops drop-in and ClassPass members who aren't expected to return.

Do not hand-roll this from max(occurred_at) on attendance, and do not use list_quadrant_members — quadrants are a relationship-graph enrichment, not a recency measure.

One member's full picture → entity_lookupget_member_context

Resolve the name or email to a canonical id first (entity_lookup with type: member), then get_member_context for connection strength, primary coach and affinity. get_member_plan_status gives the raw member record.

Note the GymMaster caveat: if their historical attendance sits under an old name, it won't be on their record. Check for stranded rows before telling an operator a long-standing member has "no history":

Schedule performance → get_class_utilisation, then get_time_slot_detail

get_class_utilisation gives the day-of-week × hour heatmap (session count, capacity, booked, mean/min/max fill) over a date range, already excluding cancelled and zero-capacity sessions and bucketing in studio-local time. Then drill into a specific weekday and hour window with get_time_slot_detail for the per-class rows.

This is the right answer to "which classes should I cancel" — and it works on GymMaster because it needs only the schedule and bookings, both of which GymMaster gives us well.

Instructor performance → get_teacher_performance

Pass staff_ref (a name — resolved server-side) rather than hunting for an id, and set include_summary: true so you get the true totals across every matching session rather than just the returned rows.

Watch for the reversed-name artefact: an instructor known only from the class schedule may be stored as "S, Amy". Resolve via entity_lookup with type: staff if a name doesn't hit.

Door traffic and walk-ins → SQL on bookings.facility_entry

There's no dedicated tool for the access log yet. Always window from the capture start date, and filter the category so a booked class isn't counted as a walk-in:

Denied entries are their own signal — status = 'denied' with a reason often surfaces expired memberships before the member notices.

Is the data current → get_system_status

Before saying anything is missing. It reports each connected source as current / stale / failing with a plain-language "current as of" date.

Counting real members → people.member_distinct

people.member is one row per source system. On a GymMaster-only studio that's harmless, but the moment Stripe is also connected the same human has two rows. Count people.member_distinct.

What doesn't work on GymMaster

Don't reach for get_member_payments, revenue queries on commerce.sale, get_cac_by_cohort (needs marketing spend), or get_retention_curve (needs recurring charges) — all of them depend on financial data GymMaster has no API for. If the studio also runs Stripe, those tools work off the Stripe side; on GymMaster alone they'll return empty and that is the correct result, not a fault.

Where this lives in the code

Concern
Path

Entity catalogue, API keys, collection modes

services/ingestors/gymmaster/internal/gmclient/specs.go

Pull + per-member walk + current-member gating

services/ingestors/gymmaster/internal/gmclient/collect.go

Fan-out transforms (classes, attendees, memberships)

services/ingestors/gymmaster/internal/transform/

1:1 SQL projections (members, staff, clubs, plans)

services/intelligence/internal/ingest/project/templates_gymmaster.go

Access-log schema (doors, categories, outcomes)

services/intelligence/migrations/postgres/org/300_bookings/009_facility_entry_door.sql, 011_facility_entry_access.sql

Door/area locations as club children

services/intelligence/migrations/postgres/org/100_people/009_location_door_model.sql

Operator-facing connect guide

Last updated

Was this helpful?