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

ClassPass — ontology map

What a ClassPass reservation-report export gives Kula Intelligence, what it cannot give, and how payouts link back to attendance recorded in the booking system.

Source id: com.classpass · Role: aggregator revenue only · Access: operator-uploaded CSV — there is no ClassPass API for studios

ClassPass is unlike every other source here. It has no studio API and no connector service. What a studio has is a reservation report they download from ClassPass and upload to Kula, and what that report carries is the payout side only.

The attendance itself is not missing — ClassPass bookings flow into the studio's booking system (Mindbody, Wix, GymMaster), so bookings.attendance already has the visit. What was missing was the money: how much ClassPass actually paid the studio for it. That is the entire job of this source.

Coverage at a glance

The export must carry these 12 columns. ClassPass ships more than one shape of this report and has added columns over time, so columns are matched by name: extra columns are carried into the raw record untouched, and any column order works. A file MISSING one of the twelve is rejected whole-file, naming what was absent — that means "not a ClassPass reservation report", not a mapping to solve.

Because the idempotency key is built from the twelve named columns only, the same reservation exported in the narrow and the wider shape hashes to the same reference and dedupes against itself. Importing both variants of an overlapping period does not double-count the payout.

venue, location, class_name, class_date, start_time, uid,
first_name, last_name, earnings, payout_reason, status, instructor_name
CSV column
Canonical home

earnings

commerce.sale.amount / .total

class_date + start_time

commerce.sale.occurred_at (studio-local)

first_name + last_name

used for matching; not written as a member

uid

ClassPass member id, retained in source_extras

status

source_extras.status — also drives the payout kind

payout_reason

source_extras (e.g. "Reactivate past members (25% Off)")

class_name, instructor_name, venue, location

source_extras, and used for matching

One commerce.sale per CSV row. No members are created, no attendance is created, no plans, no products. class_date is DD/MM/YYYY and start_time is 24-hour local.

What we cannot get

Anything ClassPass didn't put in the export. There is no API, so the CSV is the complete universe of ClassPass data. Concretely, that means:

  • No ClassPass member profile. uid is stable, but there's no email, phone, join date or history. ClassPass attendees are not created as people.member rows — they exist as members only if the booking system also recorded them.

  • No ClassPass-side booking or cancellation timeline. Only the final status per reservation.

  • No forward view. The report is historical; there's nothing to poll, so there is no "ClassPass bookings for next week".

  • No refunds or adjustments as their own rows. Corrections appear as further reservation rows in a later export.

Freshness is manual. There is no recurring sync. The data is as current as the last file an operator uploaded — which is why a ClassPass revenue figure should always be qualified by the period the uploads actually cover.

Coverage is whatever was uploaded. Gaps between exports are invisible. A missing month looks like a month with no ClassPass revenue.

Stage 2 runs set-based in SQL and matches each CSV row to an existing attendance record:

  1. Find the member by normalised name. Two keys, matching on either: name_full (lower-cased, diacritics folded, punctuation stripped, first+last concatenated — so José matches Jose and O'Leary matches OLeary), and name_sort (same fold, but tokens sorted — so Zhang, Wei matches Wei Zhang).

  2. Find that member's attendance within ±1 day of the class time. The slack absorbs timezone skew between the CSV's studio-local times and the booking system's timestamps.

  3. Pick among candidates by exact class-name-and-time, then exact time, then class name, then closest time.

The sale is created either way. An unmatched row still becomes revenue, flagged source_extras.match_status = 'unmatched'. The attendance link is fill-only — an attendance row already pointing at a sale (paid through the booking system) is never overwritten.

There is a rematch path: unmatched sales are re-run against current member and attendance data and promoted when they now match. So a studio that uploads ClassPass before finishing their booking-system import will see match rates improve after a rematch rather than being stuck.

Idempotency is sha256(uid | class_date | start_time | class_name | status). Overlapping exports collide per-row and count as duplicates. status is part of the key on purpose — a "Late Cancel" and a "Late Cancel Rebooked" for the same slot are genuinely different payouts.

Counting traps

Match rate is a real, reportable number. Query source_extras->>'match_status' and say what proportion of ClassPass revenue could be attributed to a known member. Reporting per-member ClassPass spend without that denominator overstates confidence.

No booking data means no matches at all. If the studio's booking system hasn't been imported yet, every row lands unmatched. That's expected — say so rather than reporting it as a data quality problem.

Cancellation-fee payouts are mixed in. Rows whose status contains "cancel" are cancellation-fee payouts, not attended classes. Splitting attended-class revenue from cancellation fees matters for any per-visit economics.

ClassPass revenue is not studio revenue at the same rate. A ClassPass visit typically pays a fraction of a direct booking, and payout_reason shows why (promotions, reactivation discounts). Keep it reported separately from headline membership revenue — the canonical glossary treats aggregator flows as an excluded bucket for exactly this reason.

The booking system also recorded these visits. ClassPass attendance exists in bookings.attendance with source = 'com.mindbody' (or Wix, GymMaster). Don't count the ClassPass sale as an extra visit — it's the money for a visit already counted.

Questions this source can and can't answer

Can answer well

  • What ClassPass actually paid, per reservation and in total, for the uploaded periods

  • Effective revenue per ClassPass visit, and how it compares to a direct booking

  • Which promotions and payout reasons are driving the rate

  • Which instructors and class times attract the most aggregator volume

  • Cancellation-fee income

Cannot answer

  • Who ClassPass attendees are, beyond a name and an opaque uid

  • Whether a ClassPass attendee converted to a direct member (only inferable via a name match to the booking system)

  • Anything about periods not covered by an upload

  • Forward bookings

  • ClassPass-side cancellations, waitlists or member behaviour

Importing an export from the chat screen

An operator does not have to open the console to load a ClassPass export. Three tools cover the whole cycle:

Tool
What it does

import_classpass_export

Takes the CSV as text, lands every row, creates the sales and links them to attendance — both stages in one call

rematch_classpass_sales

Re-evaluates sales still flagged unmatched, after more members or booking history arrive

get_classpass_import_status

What is already loaded, what is unmatched and why, and the per-day reconciliation against the booking roster

Pass the file through verbatim. csv_text must be a byte-for-byte copy of the export: the header line and every data row, in the original order. Do not reformat, requote, sample, collapse similar rows or stop early — each row is a separate payout, so an omitted row is revenue the studio never sees. Set expected_rows to the number of data rows you believe the file has; the tool compares it against what arrived and returns row_count_warning when they disagree, which is how an incomplete paste gets caught. If that warning comes back, say so plainly and re-import the full file rather than reporting the import as done.

The ceiling is 512 KB of CSV (roughly 4,500 reservations — comfortably a month, plausibly a quarter). Above that the tool refuses and names the console upload, which has no practical limit; that is the right path for a multi-year backfill, and it is not a failure to say so.

Re-importing is safe. Rows dedupe on a content hash of uid | class_date | start_time | class_name | status, so an overlapping export adds only what is genuinely new. Note that earnings is deliberately not part of that hash: a corrected re-export updates nothing and double-counts nothing.

After the import, read unmatched_breakdown before characterising the result. late_cancel and privacy_erased are expected and need no action — see Counting traps. Only unlinked is worth chasing, and rematch_classpass_sales is the thing to run once more members or more booking history have loaded.

Recipes — what works well

For reading the data back there is no ClassPass-specific tool — everything here is SQL over commerce.sale filtered to source = 'com.classpass'. Three things are worth doing every time.

Always establish coverage and match rate first

Two numbers to quote alongside any ClassPass figure: the period the uploads actually cover, and the proportion matched to a known member. A gap between exports looks identical to a month with no ClassPass revenue.

Effective revenue per visit, and how it compares

The NOT ILIKE '%cancel%' matters — cancellation-fee payouts are mixed in and are not attended classes. Run the inverse to report them separately.

What's driving the rate

Promotions and reactivation discounts show up here, and they explain most of the variance in what ClassPass pays.

Which classes and instructors attract aggregator volume

class_name and instructor_name ride in source_extras. Cross-reference with get_class_utilisation on the booking source to see whether ClassPass is filling classes that were already full — which is a very different conclusion from filling empty ones.

Keep it out of headline revenue

The canonical glossary treats aggregator flows as an excluded bucket. Report ClassPass payout as its own line, not folded into membership revenue, and never count a ClassPass sale as an extra visit — the visit is already in bookings.attendance under the booking system's source.

What doesn't work

No purpose-built tool applies. list_at_risk_members deliberately excludes ClassPass members — they aren't expected to return, so treating them as lapsing is noise. Per-member ClassPass analysis is limited to whatever matched; the unmatched remainder is real revenue with no known member and should be reported as its own bucket.

Where this lives in the code

Concern
Path

The engine — parsing, Stage 1 landing, Stage 2 projection, rematch, reconciliation

services/intelligence/internal/classpass/

Console front door (multipart upload, Kinde-gated)

services/intelligence/internal/admin/httpapi/ingestor_classpass.go

Chat front door (MCP tools, on mcp-runtime)

services/intelligence/internal/tools/classpass_v2.go

Operator UI

/ingestors/classpass in apps/kula-org-admin

Sample export shape

services/ingestors/classpass/demodata/README.md

Operator-facing connect guide

Last updated

Was this helpful?