Mindbody — ontology map
What Mindbody gives Kula Intelligence, what it cannot give, and how its clients, classes, visits, sales and contracts map into the canonical model.
Source ids: com.mindbody (operational) and com.mindbody.billing (sales) · Role: booking + membership + point-of-sale system of record · Access: Public API v6, per-site
Mindbody is the most complete single source we ingest for a studio: it knows the schedule, who attended, what they bought, and on what terms. Where it falls down is membership structure — there is no memberships endpoint, so a member's plan has to be inferred.
Note the two source ids. Operational entities land under com.mindbody; sales land under com.mindbody.billing. A query filtering source = 'com.mindbody' on commerce.sale returns nothing.
Coverage at a glance
/site/locations
people.location
one per site
full
each poll
/staff/staff
people.staff
one per staff member
~12 months from the direct pull (older instructors backfilled from classes)
each poll
/sale/services
commerce.plan
one per pricing option
full
each poll
/site/categories
bookings.category
one per class/service category
full
each poll
/sale/contracts
commerce.plan
one per contract item
full, per location
each poll
/sale/products
commerce.product
one per retail product
full
each poll
/client/clients
people.member
one per client
full
each poll
/sale/sales
commerce.sale (+ commerce.payment)
one row per sale line, not per sale
date-windowed
each poll
/class/classes
bookings.class_session (+ staff/location stubs)
one per scheduled class
date-windowed
each poll
/class/classvisits
bookings.attendance (+ plan stubs, member plan refresh)
one per visit
date-windowed, fetched per class
each poll
What we cannot get
Memberships as a vendor enrolment record. MBO v6 has no /sale/memberships endpoint — it 404s on live sites. What a studio calls a "membership" is represented two ways, and we pull both: services (sellable pricing options) and contracts (recurring agreements). Both map into commerce.plan. There is no vendor object that says "this member holds this membership from this date to this date", so:
Membership start/end dates, freezes and holds are not available. There is no enrolment record to read them from.
A member who never attends has no plan on file. Nothing to infer from.
What we do have instead is better than it sounds — see below.
A trustworthy member status from MBO. MBO does not reliably tell us when a member lapses. We work around it by deriving the status from behaviour nightly — see below — so people.member.status is dependable; MBO's own flag is not, and nothing recovers the moment a member actually stopped attending.
Status history before we started capturing it. MBO exposes only the member's current status — there is no change log and no effective-dated history, so "when did she go inactive?" is unanswerable for anything that happened before Kula began synthesising the event itself. From that date forward it is answerable: every status, membership-type, plan and suspension change is captured in people.member_status_event (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. Establish the capture start before charting a series:
Appointments / 1:1 sessions. Not supported. bookings.appointment is empty for MBO studios — nothing about PT sessions, 1:1 bookings or appointment revenue can be answered from this source.
Door access / facility entry. No MBO endpoint. bookings.facility_entry is empty.
An "all visits" feed. There is no endpoint that lists visits directly. Visits are reached by listing classes in a window, then calling classvisits per class id. Consequence: a visit is only ever ingested if its class was ingested first. A gap in the class window is also a gap in attendance, and attendance for a period cannot be back-filled without re-pulling that period's classes.
Currency. MBO carries no currency on sales — each site is single-currency. We stamp a configured default (MBO_DEFAULT_CURRENCY, AUD on the AU cell). If no currency is configured the ingestor skips payment emission entirely rather than write an invalid row, so commerce.payment can legitimately be empty even where commerce.sale is full.
Timezone on schedule times. MBO returns naive datetimes with no zone. They are interpreted using the studio's configured IANA timezone. A studio with a missing or wrong timezone setting will have class times shifted.
Identity and join keys
Client
Id (also UniqueId)
people.member.source_external_id
Staff
Id
people.staff.source_external_id
Location
Id
people.location.source_external_id
Class
Id
bookings.class_session.source_external_id
Visit
Id, with ClassId + ClientId
bookings.attendance
Sale line
{Sale.Id}:{SaleDetailId}
commerce.sale.source_external_id
MBO attendance carries a real client id, so attendance joins cleanly to people.member — no name matching is involved. Visits also carry a rich embedded Service object, which is where the plan attribution on a visit comes from — keyed on Service.ProductId, not Service.Id (the latter is a per-purchase instance and would explode the plan table).
MBO's member status is not trustworthy
people.member.status comes straight from MBO's Active / IsProspect flags. MBO does not reliably update it when a member lapses, and re-checking every member against the MBO API is too expensive to do at studio scale. So the field says what MBO last claimed, not what is true.
How wrong it gets: one live MBO org had 2,748 members marked active whose most recent class was in 2025 or earlier, 808 of them not seen since 2024. Roughly half that member base was carrying a status that said otherwise — and, because the plan is stamped from the last visit and never cleared, a plan name to match.
Never treat status = 'active' as "currently a member" on MBO without checking behaviour. Any count of active members, members-on-plan, or revenue-per-active-member reads high — often by a factor approaching two.
Derive it from activity instead
The reliable signal is data we already hold: a class visit (bookings.attendance) or a payment (commerce.sale under com.mindbody.billing). A member with neither in the last 30 days has effectively lapsed, whatever MBO says.
Two exclusions matter when you apply this. A member with is_booking_suspended is on a deliberate hold, not lapsed. And a member whose member_since is inside the window simply hasn't had time to attend yet.
We correct it automatically, every night
Kula does not leave status as MBO reports it. A nightly job re-derives it for every MBO org:
A member marked
activeis switched toinactivewhen they have had no class visit AND no payment for 30 days.
Both conditions must hold — a member who is still being billed stays active even if they haven't attended, and a member who attends stays active even if nothing has been charged in the window.
It also runs in reverse. The moment real activity reappears — a visit or a payment inside the window — the member is switched back to active and the field is handed back to MBO. A member who takes two months off and returns is corrected in both directions without anyone intervening.
With one important qualification: only a member MBO still calls active comes back as active. If MBO has said something real in the meantime — cancelled, suspended, prospect — that verdict already won (the correction only ever suppresses a stale active), and handing the field back leaves MBO's value untouched. Someone who cancels their membership and then buys a single drop-in class stays cancelled; they are not resurrected as an active member by the visit.
Two deliberate exclusions stop it doing harm:
A member on a hold (
is_booking_suspended) is never swept. A deliberate pause is not a lapse.A member who joined inside the window is never swept. Someone who signed up three weeks ago and hasn't booked yet is new, not lapsed.
This override outranks MBO. The derived value is stored in people.member.status_override — a column the ingest projections do not own — and the MBO clients projection consults it, so a restate can no longer overwrite a derived inactive with MBO's stale active. Earlier versions of this correction were undone by the next nightly restate; that is fixed.
A vendor value other than active still wins immediately. If MBO reports a member cancelled or suspended, that is positive evidence of a real change and it takes effect regardless of the override.
Not every "active" member is a member — some are just enquiries
After the correction runs, a cohort remains marked active with no plan, no attendance and no payment. These are not a data fault and not lapsed members: they are people who registered interest and never converted — a walk-in who gave their details, a web enquiry, someone who created an account and never booked.
They stay active on purpose. The rule never sweeps a member whose member_since falls inside the window, because someone who joined three weeks ago and hasn't booked yet is new, not lapsed. Once they age past the window with still no activity, the next nightly run sweeps them like anyone else.
MBO doesn't help distinguish them: its IsProspect flag is false on these records and Active is true, so they arrive looking exactly like paying members. The combination of active + no plan + no activity is what identifies them.
Count them separately from members. Folding them into an active-member number overstates the business; calling them churn overstates the problem. They are a lead list — and a useful one, because a studio that accumulates hundreds of unconverted enquiries has a conversion problem worth naming. still_in_grace is this month's crop; older_unconverted should be near zero once the nightly rule has been running, since it sweeps them on age.
Reading it
status is the corrected value, so ordinary queries and every tool (list_at_risk_members included) get the truthful answer with no special handling. When you need to know which answer you're looking at:
status_override IS NULL means you are seeing MBO's own claim. MBO's raw client payload is always retained in source_extras if you need to compare.
The rule refuses to run on untrustworthy data. Reading "no activity" as "lapsed" is only valid while attendance is actually arriving — if the MBO connector broke, the same logic would mark a healthy studio's entire member base inactive 30 days later. So the job checks first that attendance is current (something within 7 days) and deeper than the window, and skips the org entirely otherwise. If a studio's statuses look stale, check get_system_status — a skipped org is usually a broken connector, not a broken rule.
Three things worth saying out loud when you report on this:
The 30-day window is a judgement call, not a fact. If a studio defines lapsed differently, say which window produced your numbers.
A corrected count will be lower than MBO's own dashboard, sometimes dramatically. That's the point — but an operator comparing the two deserves to be told why they differ rather than left to assume one is broken.
The most valuable question this raises
An "active" member who hasn't attended in months but is still being billed is live revenue at acute churn risk. That's a very different finding from a stale record nobody cleaned up, and the two are trivial to tell apart:
Note that list_at_risk_members caps its look-back at 365 days, so members absent longer than that fall outside it entirely. For the long tail, this SQL is the right instrument.
Every visit records the plan it was drawn against
This is MBO's compensation for having no enrolment record, and it is worth more than a static membership field.
Each visit names the pricing option the member consumed, and that lands in two places:
bookings.attendance.plan_source_external_id— a loose FK tocommerce.plan, on every single attendance row.people.member.current_plan_id/plan_name— refreshed from the member's most recent visit, applied behind aplan_as_ofwatermark so out-of-order restates converge on their latest visit rather than the latest vendor edit.
So two things are answerable that a plain membership field could not answer:
A member's current plan, kept live as they attend.
Their plan history — because the plan is stamped per visit, you can see exactly when someone moved from an intro pack to a membership, or upgraded mid-cycle. Their effective plan timeline is reconstructable from attendance even though MBO exposes no enrolment dates.
Two caveats to state when you use it:
It is the plan as at each visit, not a vendor-asserted enrolment. A member who stopped attending carries the plan they last attended on — read
statusfor whether they're current.plan_source_external_idis mutable. Operators reclassify which plan covered a class when someone upgrades mid-cycle or a wrong assignment is corrected, so a snapshot of this column can change retroactively. Readbookings.attendance.updated_atif you need to detect that.
Counting traps
Sales are per line, not per sale. One MBO sale with three purchased items becomes three commerce.sale rows. Counting rows counts line items. Revenue is correct because each line carries its own total; transaction count is not — count DISTINCT on the sale id portion of source_external_id.
A contract sale can split across rows. Reconcile via source / source_external_id rather than assuming one row per agreement. A sale line with item_type = 'contract' should be looked up in commerce.plan for its full terms.
classpass does not mean ClassPass. On MBO, commerce.plan.category = 'classpass' — and the membership_type derived from it — means the pricing option has a session count: a 10-pack, a 20-pack, a class pass. Unlimited options get 'membership'. It says nothing about the ClassPass aggregator.
The two are easy to confuse and the mistake is expensive. One live org shows 7,898 classpass against 1,592 membership — that is a studio whose members mostly buy packs, not a studio overrun by an aggregator. Real aggregator revenue is identified by the ClassPass export and by sale provenance, never by this field.
It also has a quiet consequence: list_at_risk_members filters membership_type IN ('membership','intro'), so pack members are excluded from the at-risk board by design. In a pack-heavy studio that removes most of the member base — worth saying explicitly rather than presenting a short list as the whole picture.
Aggregator flows arrive here. ClassPass and MBO-Online bookings land as ordinary MBO sales and visits. They should be tagged and reported separately from headline studio revenue — a ClassPass visit is worth a fraction of a direct booking, and the true payout only appears if the studio also uploads their ClassPass export.
Attendance keeps multiple rows per (member, session) on purpose. The lifecycle — booked, cancelled, re-booked, attended — is signal we want. Fix double counting at the read layer by taking the latest status, never by de-duplicating at ingest.
Instructors older than ~12 months come from class records, not the staff pull. Every class embeds its full instructor object, so we emit fill-only staff rows from classes to backfill them. Those rows are sparser than the direct pull. Don't read a thin staff row as "this instructor left".
SignedIn: true means attended. MBO's AppointmentStatus field is unreliable for class visits; the visit's own signed-in/no-show flags are the truth. This is already handled at transform, but matters if you're reading ingest.raw_record directly.
Questions this source can and can't answer
Can answer well
Full class schedule, capacity, utilisation, instructor per class
Who attended, who no-showed, who late-cancelled — with real member ids
Revenue by line item, by product, by pricing option, by date
Retail vs service revenue split
What pricing options and contracts exist, at what price and interval
Retention and at-risk analysis on real attendance history
Which plan a member is currently on, and when they changed plans — from the plan stamped on each visit
Who is genuinely still a member —
statusis corrected nightly from activity rather than trusting MBO's flagWho registered interest and never converted — active, no plan, no activity; a lead list, not a member count
Cannot answer from MBO alone
The moment a member actually lapsed (MBO never reports it; our nightly rule detects it 30 days after their last visit or payment, so the date is a detection date, not a cancellation date)
The contractual start or end date of a membership (no enrolment record — you can see when they started attending on a plan, which is not the same thing)
Membership freeze/hold periods (same)
A plan for a member who has never attended (nothing to infer from)
1:1 appointments and PT sessions (not supported)
Door access / walk-ins (no API)
Current plan for a member who hasn't attended recently (inferred from last visit)
True ClassPass payout (MBO records the booking; the payout is in the ClassPass export)
Recipes — what works well
MBO is the most tool-friendly source we have: real member ids on attendance, real money, real categories. Almost every purpose-built tool works properly here.
Who's lapsing → list_at_risk_members
Buckets active members by days since last visit, pause-aware, excluding drop-in and ClassPass members. On MBO this is high quality because attendance carries a real client id. Don't hand-roll it, and don't use list_quadrant_members — quadrants are graph enrichment, not recency.
Schedule performance → get_class_utilisation → get_time_slot_detail
The day-of-week × hour heatmap, then the per-class drill-down for a chosen weekday and hour window. Both read the guarded views, exclude cancelled and zero-capacity sessions, and bucket in studio-local time — which matters on MBO, whose datetimes are naive and interpreted with the studio timezone.
Instructor performance → get_teacher_performance
Pass staff_ref (name) and include_summary: true. Set include_subs when you want sessions where they assisted, not just led.
Remember instructors older than ~12 months arrive as sparse rows backfilled from class records — a thin staff row is not evidence someone left.
Revenue → SQL on commerce.sale, minding the grain and the source
Sales land under com.mindbody.billing, not com.mindbody, and one row is one line, not one sale:
Net revenue subtracts commerce.refund — refunds are their own rows, never negative sales.
Split aggregator revenue out
ClassPass and MBO-Online flows arrive as ordinary MBO sales. Report them separately from headline membership revenue, and if the studio also uploads their ClassPass export, the true payout is there rather than in MBO.
A member's plan history → SQL on bookings.attendance
The plan is stamped per visit, so plan changes are a DISTINCT ON away. This is the closest thing MBO gives to a membership timeline:
Say "the plan they attended on" rather than "their membership" — it's an effective plan, not a contractual one, and members who stopped attending carry their last one.
One member's money → get_member_payments
Combines commerce.sale with any standalone gateway payments not linked to a sale, with a record_kind column discriminating the two. Better than querying either table alone.
Retention → get_retention_curve
Cohort × period survival for members on a recurring membership. Bucket by signup_month (default), plan, or location. Class-packs, drop-ins, intros and comps are excluded by design — on MBO that exclusion is meaningful, because pricing options and contracts are mixed in commerce.plan.
Acquisition cost → get_cac_by_cohort
Works if the studio also has Meta or GA4 connected for spend. The cohort denominator is members whose first attended class was in that month — which MBO supports properly.
Is the data current → get_system_status
Reports each connected source as current / stale / failing with a plain-language "current as of" date. Check it before concluding anything is missing.
What doesn't work on MBO
bookings.appointment and bookings.facility_entry are empty — appointments aren't supported, and there's no access API. Membership start/end/hold dates don't exist, so anything phrased as "when did their membership begin" has to be answered from their first sale or first visit instead, with that substitution stated.
Where this lives in the code
Entity catalogue, endpoints, paging modes
services/ingestors/mbo/internal/mboclient/specs.go
Fan-out transforms (classes, visits, sales, contracts)
services/ingestors/mbo/internal/transform/
1:1 SQL projections (clients, staff, locations, products, services)
services/intelligence/internal/ingest/project/templates_mbo.go
Operator-facing connect guide
Last updated
Was this helpful?