Datastream and CMS API

Specs: dsapi OpenAPI · cmsapi OpenAPI · 95 operations

Datastream · ops 4
GET /status Datastream

Health check

Unauthenticated. Runs a single trivial query against the mirror to confirm the pool can reach the database — not a check of sync freshness or any individual site. A 200 means "the API can talk to Postgres," nothing more.

GET /status/schema-assumptions Datastream

Fields resting on unverified JSON paths

The reconciliation checklist. Each entry is a mapped field whose `_document` path was not observed in the schema dump.

GET /v1/docs Datastream

This document

The live spec, generated from this same file — served as YAML so it stays diffable and matches what's committed to the repo. api-fvmgt's combined API docs page (/) fetches this alongside api-funnel's /v1/docs and renders both as one operation list.

GET /v1/stream Datastream

Live change stream (SSE)

Server-Sent Events, not a normal request/response call — the connection stays open and pushes an event each time something this key can read changes (bookings, check-ins, roster updates). Read scope only: subscribing tells a caller what changed, it never lets them change anything, so a `raw:read`-only check-in screen and a `booking:write` widget can both listen on the same key. Long-lived by design, so none of the usual response machinery applies here — no envelope, no ETag, no pagination, no Cache-Control. The rate limiter in `authenticate` still runs once at connect time, which is what stops one caller from opening hundreds of these.

Parameter Required Type Allowed values
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
Datastream · schedule 12
GET /v1/schedule Datastream

Class schedule

Backed by `ds_mbo.class`. Defaults to a single day (today) — an unbounded default over 196k rows is not useful. Use either `date`+`days` (the legacy pair) or `start_date`+`end_date`, not both.

Parameter Required Type Allowed values
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
date (query) No string (date) Window start. Defaults to today (UTC).
days (query) No integer Window length in days from `date`.
start_date (query) No string (date)
end_date (query) No string (date) Inclusive.
location_id (query) No string
location (query) No string Name-based sibling of `location_id`, for a caller that only has the studio's display name on hand. Exact match against `location_name`.
staff_id (query) No string
teacher_id (query) No string Deprecated alias for `staff_id`, retained from the legacy API.
teacher (query) No string Name-based sibling of `staff_id`/`teacher_id`, for a caller that only has the teacher's display name on hand. Exact match against `staff_name`.
category (query) No string
class_type (query) No string Matches the class description's `sessionType`. Common values include `Class`, `Workshop`, `Event`, `Community`, `Retreat`, `Teacher Trainings` — see `session_type` on `/v1/events` for the full set actually in use at a site.
include_cancelled (query) No boolean Cancelled classes are excluded by default — MBO records a discontinued recurring schedule the same way as a one-off cancellation, so unfiltered results include classes the studio no longer offers. Pass `true` to include them.
modified_since (query) No string (date-time) Return only rows the mirror updated at or after this instant.
limit (query) No integer
offset (query) No integer
include_inactive (query) No boolean Include rows the mirror has flagged inactive or removed.
GET /v1/schedule/{class_id} Datastream

One class

A single scheduled class instance by its MBO `class_id`, joined the same way `/v1/schedule` is (room, staff, description). `class_id`s are per-site sequential and collide across tenants — this key's `SiteScope` is what keeps a lookup from crossing into another studio.

Parameter Required Type Allowed values
class_id (path) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/class-descriptions/{class_schedule_id} Datastream

One class type's description

The class name, category, and full HTML description for a class *type* (e.g. "Flow"), keyed by `class_schedule_id` — the same field every `/v1/schedule` row carries. Split out of `/v1/schedule` because the description text repeats verbatim across every instance of a class type and was ~52% of that endpoint's payload by weight; this route is meant to be fetched on demand (e.g. when a user expands a class for details) and cached long by the client, not called once per schedule row.

Parameter Required Type Allowed values
class_schedule_id (path) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/schedule/class Datastream

One class (legacy query-param form)

Identical response to `/v1/schedule/{class_id}` — same lookup, same join, same site scoping — just `class_id` as a query param instead of a path segment. Retained so legacy consumers repoint without a path change; new integrations should use the path form.

Parameter Required Type Allowed values
class_id (query) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/schedule/{class_id}/roster Datastream

Class roster

Backed by `ds_mbo.class_visit`, joined to `ds_mbo.client` on `(client_id, site_id)` — never on id alone, since MBO ids collide across sites. Freshness caveat: as of 2026-08-05, `realtime`-tier sites (currently Flow and Flow Yoga Georgetown) get roster changes pushed via webhook — a booking, cancellation, or membership change typically lands in the mirror within 1–3 minutes. That is **event-driven, not polled**: a class with no new activity since the switch still reflects whatever the last scheduled pass (08:00/18:00/23:00 UTC) wrote, which can be hours old even for a class starting soon. `daily`/`paused` sites still rely solely on the scheduled passes. Confirm a class has a recent `sync_dirty_entity` fetch before trusting it as live.

Parameter Required Type Allowed values
class_id (path) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
modified_since (query) No string (date-time) Return only rows the mirror updated at or after this instant.
limit (query) No integer
offset (query) No integer
POST /v1/schedule/{class_id}/bookings Datastream

Book a client into a class

Adds a client to a class. Requires the `booking:write` scope, and is served only where the deployment has writes enabled. **This does not write to Datastream.** The booking is proxied to Mindbody, which is the system of record; `ds_mbo.class_visit` picks it up on the next sync, so a roster read straight after a booking will not show it yet. Datastream still flows one direction. The class is resolved inside the key's site scope first — a class the key cannot read is a `404`, not a `403`, so the endpoint never confirms a class it will not serve. The Mindbody site and staff login used are the ones registered for that class's site. Business rules are Mindbody's: capacity, eligibility, late-booking windows and payment are its call, and its own message comes back verbatim on a `422`. The single precondition checked here is a cancelled class, which is a `409`. Send `test: true` to have Mindbody validate the booking without creating one. The response echoes `test` — a consumer that ignores it will read a dry run as a real booking. A dry run returns `visit_id: 0`, since Mindbody answers with a placeholder visit rather than none. **Mindbody does not dedupe.** The same client booked into the same class twice gets two distinct visits — verified in production, and the mirror cannot be used to pre-check because it syncs three times a day. Send an `Idempotency-Key` and a repeat replays the first response instead of booking again. Without one, a retry or a double-click creates a duplicate.

Parameter Required Type Allowed values
class_id (path) Yes string
Idempotency-Key (header) No string Opaque, caller-chosen, unique per intended booking. A repeat within 24 hours replays the first response and sets `Idempotency-Replayed: true` rather than booking again. Failures are not recorded, so a key frees up for a genuine retry. Scoped to the API key.
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
DELETE /v1/schedule/{class_id}/bookings Datastream

Remove a client from a class

Cancels a client's place. Requires `booking:write`. Scoped to the class, not a visit id, because Mindbody's `removeclientfromclass` takes `(client_id, class_id)` and has no per-visit form. One call clears **every** visit that client holds in the class, so it is the cleanup for the duplicates `POST` can create. Not a no-op when there is nothing to remove: a second call returns `422` with "No class visit found…". Useful as a way to confirm a client is clear. `late_cancel` defaults off, so the forgiving outcome — credit returned, no penalty — is the one you get by accident.

Parameter Required Type Allowed values
class_id (path) Yes string
client_id (query) Yes string
late_cancel (query) No boolean Apply the studio's late-cancel penalty instead of returning the credit.
send_email (query) No boolean
test (query) No boolean Validate against Mindbody without removing anything.
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
POST /v1/schedule/{class_id}/roster/{visit_id}/check-in Datastream

Mark a booked client signed in (or undo it)

Flips `signed_in` on a visit the client already holds. Requires the `checkin:write` scope — deliberately separate from `booking:write`, since this cannot add, remove, or move anyone's booking, only mark arrival on a booking that already exists. `visit_id` is the roster row's `booking_id`, from a prior `GET /v1/schedule/{class_id}/roster`. It is not independently re-verified against the class — the same trust model `DELETE .../bookings` already uses for a caller-supplied `client_id` — but the Mindbody session used is scoped to the class's site, so a visit id from another site fails there, not here. **Known gap:** unlike book/cancel, this write is not yet reflected by the read-after-write overlay. A roster read immediately after still shows the mirror's stale `signed_in` for anyone but the caller, who should trust this response instead.

Parameter Required Type Allowed values
class_id (path) Yes string
visit_id (path) Yes string
Idempotency-Key (header) No string Opaque, caller-chosen, unique per intended booking. A repeat within 24 hours replays the first response and sets `Idempotency-Replayed: true` rather than booking again. Failures are not recorded, so a key frees up for a genuine retry. Scoped to the API key.
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
PATCH /v1/schedule/{class_id}/teacher Datastream

Assign a teacher or substitute to one class instance

Sets the `staff_id` on a single scheduled class instance via Mindbody's `updateclass`. Requires the `schedule:write` scope, and is served only where the deployment has writes enabled. This is the "sub" verb: Mindbody itself decides whether the result reads as a substitute by comparing the instance's staff to its recurring schedule's own default — this endpoint does not set that flag directly. To change the schedule's *permanent* teacher instead (the default every future-generated instance inherits), use `PATCH /v1/schedule-definitions/{class_schedule_id}/teacher`. The class is resolved inside the key's site scope first, same as `POST .../bookings` — a class the key cannot read is a `404`. A cancelled class is a `409`. **Does not write to Datastream and is not yet reflected by the read-after-write overlay** — a schedule read immediately after still shows the mirror's stale teacher until the next sync.

Parameter Required Type Allowed values
class_id (path) Yes string
Idempotency-Key (header) No string Opaque, caller-chosen, unique per intended booking. A repeat within 24 hours replays the first response and sets `Idempotency-Replayed: true` rather than booking again. Failures are not recorded, so a key frees up for a genuine retry. Scoped to the API key.
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
POST /v1/schedule-definitions Datastream

Publish a new recurring class

Creates a recurring class schedule via Mindbody's `addclassschedule` — the class type, location, days/times, and its **permanent teacher** in one call. Mindbody generates the individual instances; the mirror picks them up on its next sync. Requires `schedule:write`, and is served only where the deployment has writes enabled. `site_id` is required here (not just a narrowing filter): there is no existing mirror row to read tenancy off before the class exists, so the caller's own site scope is checked directly against it. It also supplies Mindbody's `LocationId`.

Parameter Required Type Allowed values
Idempotency-Key (header) No string Opaque, caller-chosen, unique per intended booking. A repeat within 24 hours replays the first response and sets `Idempotency-Replayed: true` rather than booking again. Failures are not recorded, so a key frees up for a genuine retry. Scoped to the API key.
site_id (query) Yes string The 32-character Datastream site id to publish into. Must be one of the key's own sites — anything else is a 403.
PATCH /v1/schedule-definitions/{class_schedule_id}/teacher Datastream

Change a recurring schedule's permanent teacher

Sets `staff_id` on the recurring class schedule itself via Mindbody's `updateclassschedule` — the default every future-generated instance inherits, not just one occurrence (see `PATCH /v1/schedule/{class_id}/teacher` for that). Requires `schedule:write`, and is served only where the deployment has writes enabled.

Parameter Required Type Allowed values
class_schedule_id (path) Yes string
Idempotency-Key (header) No string Opaque, caller-chosen, unique per intended booking. A repeat within 24 hours replays the first response and sets `Idempotency-Replayed: true` rather than booking again. Failures are not recorded, so a key frees up for a genuine retry. Scoped to the API key.
site_id (query) Yes string The 32-character Datastream site id the schedule belongs to. Must be one of the key's own sites — anything else is a 403.
GET /v1/schedule/roster Datastream

Class roster (legacy query-param form)

Everyone booked into one class — name, booking status, whether they checked in. `class_id` as a query param rather than a path segment; kept for callers that haven't repointed to the equivalent path-form endpoint.

Parameter Required Type Allowed values
class_id (query) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
Datastream · sales 15
POST /v1/purchases Datastream

Check out a catalog item for a client

Sells one service (package/drop-in) or retail product to a client via Mindbody's `checkoutshoppingcart`. Requires the `purchase:write` scope, and is served only where the deployment has writes enabled. **This does not write to Datastream.** The cart is proxied to Mindbody, the system of record; the sale reaches `ds_enriched.sales` on the next sync. Datastream still flows one direction. The item is resolved inside the key's site scope first — an item the key cannot read is a `404`, never a `403`. **`test` defaults TRUE**, the opposite of a booking: this endpoint moves money on a client's account, so a real sale must say `test: false` out loud. A dry run has Mindbody validate and price the whole cart (tax included) and returns the totals with `sale_id: null`. Mindbody owns the total, tax included, and refuses a payment off by a cent. Omit `payment_amount` and the endpoint resolves the authoritative total itself (one retry with Mindbody's own calculated figure). An explicit `payment_amount` is never corrected — a mismatch comes back as a `422` carrying Mindbody's message with the real total in it. **No card is charged.** The payment is recorded against a Mindbody custom payment method (e.g. 19 = "Square" on the Flow site), which is a label, not a processor. Whatever collects the actual money does so before calling this. Mindbody does not dedupe sales. Send an `Idempotency-Key` and a repeat replays the first response instead of charging the account again.

Parameter Required Type Allowed values
Idempotency-Key (header) No string Opaque, caller-chosen, unique per intended booking. A repeat within 24 hours replays the first response and sets `Idempotency-Replayed: true` rather than booking again. Failures are not recorded, so a key frees up for a genuine retry. Scoped to the API key.
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/payments/square/config Datastream

Square Web Payments SDK bootstrap values for one Mindbody location

Application id, environment, the SDK script URL, and the Square location that `location_id` (Mindbody's) maps to — a console page needs all of it to render a card form and call `POST /v1/payments/square/charge`. Requires only a valid key — none of this is secret; the Web Payments SDK ships the application id to the browser by design. **`location_id` is required.** One Square merchant account (one application id, one access token) serves every Flow studio, but Mindbody's `location_id` and Square's own location id are different id spaces with no natural correspondence, so the mapping lives in `ds_api.square_location_map` (`src/db/square-locations.ts`), one row per (site, Mindbody location). Tenancy is proven the same way `/v1/purchases` proves it for an item: the location is read inside the key's scope first, a `404` for one it cannot see, never a `403` that confirms it exists. Returns `503` when the account itself isn't configured (`SQUARE_APPLICATION_ID` / `SQUARE_ACCESS_TOKEN`), or when this specific location has no Square location mapped yet.

Parameter Required Type Allowed values
location_id (query) Yes string Mindbody's location id (from `GET /v1/locations`).
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
POST /v1/payments/square/charge Datastream

Charge a card via Square and check out the cart in Mindbody

The endpoint `/v1/purchases` deliberately does not have: this one actually moves money. Requires the `payment:write` scope, kept separate from `purchase:write` because it can debit a card, and is served only where the deployment has writes enabled and Square is configured. Runs three steps, in order, per the square-mbo-payments design (verified against production Mindbody and the Square sandbox, 2026-07-30): 1. A `test: true` Mindbody checkout to learn the authoritative total — tax included — **before Square is touched**. 2. `POST /v2/payments` at Square for exactly that figure, using `source_id` (the Web Payments SDK token from the browser; a card number is never seen server-side). 3. A `test: false` Mindbody checkout at that same figure, recorded against the deployment's Square custom payment method (`SQUARE_MBO_PAYMENT_METHOD_ID`, 19 on the Flow site). **If step 3 fails after step 2 succeeded**, the card has already been charged. This endpoint refunds it automatically, alerts through flow-notify, and returns `422` naming what happened either way — it never leaves a charged-but-unfulfilled payment silent. Mindbody discards any payment reference it is given (`Payments[]. Metadata.Notes` verified stored `NULL`), so the Square payment id and the Mindbody sale id are linked only in this service's own ledger (`ds_api.square_payment`) — not automation for refunds issued in the Mindbody admin panel; that is a separate, unbuilt design. **`Idempotency-Key` is required, not optional** — unlike `/v1/purchases`, where the worst case of a dropped retry is a duplicate sale. Here it is a duplicate real charge.

Parameter Required Type Allowed values
Idempotency-Key (header) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/payments/square/plans Datastream

Square subscription plan variations on this merchant account

Every `SUBSCRIPTION_PLAN_VARIATION` in the Square Catalog, flattened with its parent plan's name. `amount` is **dollars**, not Square's cents, matching every other money field in this API. Not site-filtered, and it cannot be: a Square Catalog belongs to the merchant account, not to a Datastream site. Behind `payment:write` rather than a read scope because it calls Square with the merchant credential — this is not a Datastream read, and `raw:read` keys are minted freely for developers.

POST /v1/payments/square/plans Datastream

Create a Square subscription plan with one variation

Creates a `SUBSCRIPTION_PLAN` and its first variation in one call. **A Square Catalog object cannot be deleted through this API**, only ignored, which is why `Idempotency-Key` is required and why this sits behind `ENABLE_WRITES` like every other upstream state change. Omit `periods` for a plan that renews indefinitely — that is what 18 of Flow's 25 Mindbody contracts actually are (`ContractAutomaticallyRenews`). The 6 that stop after a year are `periods: 12`. Prices are tax inclusive, matching Mindbody's own contract totals.

Parameter Required Type Allowed values
Idempotency-Key (header) Yes string
GET /v1/payments/square/subscriptions Datastream

A client's Square subscriptions, live status joined to the Mindbody link

Square is authoritative for `square_status`; this service's own `ds_api.square_subscription` is authoritative for which Mindbody contract a subscription stands for, because Square's Subscription object has no `reference_id` and no metadata to hold one. A row recorded here but absent at Square comes back with `square_status: null`, and a subscription at Square with no local row comes back with `recorded_status: "not_recorded"`. Neither is dropped — that divergence is exactly what a reconciliation job looks for. `is_winding_down` is the field to read, not `status`: Square keeps a cancelled subscription `ACTIVE` until the period already paid for ends.

Parameter Required Type Allowed values
client_id (query) Yes string Mindbody client id.
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
POST /v1/payments/square/subscriptions Datastream

Enrol a client on a Square subscription (starts real recurring billing)

Find-or-create the Square Customer, store the card against it, create the subscription — in that order. Square charges the first period immediately unless `start_date` is in the future, so **a 201 means real money has already moved**. **Nothing is written to Mindbody.** The response carries `mbo_entitlement: "none"` rather than letting a caller infer from a 201 that the member can book a class. The per-cycle Mindbody write hangs off the `invoice.payment_made` webhook and is not built: Mindbody's `/sale/purchasecontract` accepts no custom payment method (only `CreditCardInfo`, `StoredCardInfo`, `UseDirectDebit`, `UseAccountCredit`), so there is no way to create a Mindbody contract that Mindbody will not also try to bill, and which write replaces it is an open decision. Send **exactly one** of `source_id` (a fresh Web Payments SDK token to save) or `card_id` (a card already on file for this client; it is checked against the customer's own cards before use). Only credit and debit cards can be stored — Apple Pay, Google Pay, Cash App Pay and ACH cannot, so a token from those flows will be rejected by Square. A client with neither a name nor an email address in Mindbody is refused with `409`: Square silently deactivates such a subscription at the next billing cycle.

Parameter Required Type Allowed values
Idempotency-Key (header) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
POST /v1/payments/square/subscriptions/{subscription_id}/cancel Datastream

Cancel a Square subscription at the end of the paid period

Square's only cancel semantics, and the right ones: the member keeps what they have already paid for. The subscription stays `ACTIVE` with a `canceled_date` until that date passes, so read `is_winding_down`, not `status`. Tenancy comes from this service's own record of the subscription, so a subscription created outside this service — straight in the Square dashboard, say — cannot be cancelled here. That is deliberate: the row is the only thing tying a Square subscription to a Flow site, and cancelling on a caller-supplied id alone would let any `payment:write` key cancel anything on the merchant account. Changes nothing in Mindbody, because nothing was created there.

Parameter Required Type Allowed values
subscription_id (path) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/payments/square/webhook-events Datastream

Square webhook deliveries this service has received

The audit trail behind `POST /webhooks/square`, most recent first — including deliveries whose signature failed to verify, flagged by `signature_verified`, because a forged or misconfigured delivery is exactly what you want a record of. Not site-scoped: a Square webhook envelope carries a merchant id and a Square location id, neither of which maps to a Datastream site without first reading the payload.

Parameter Required Type Allowed values
limit (query) No integer
GET /v1/sales Datastream

Sale headers from the enriched layer (scope enriched:read)

One row per sale with item_count / payment_count / sale_total / payment_total rollups — most reads never need the line tables. `sale_date` is date-only; `transacted_at` is the datetime when known (prefer it for clocks/sorting). Requires at least one narrowing filter. `adjusted=true` returns 400 until sale_adjusted exists (PLAN.md §9): refusing loudly beats silently serving unadjusted numbers. A sale made through `POST /v1/purchases` appears here before the mirror has it, carrying `source: "pending_write"` and counted in `total_count`. It retires the moment the sync carries the same sale id. Such a response is `Cache-Control: no-store`. The same applies to `/v1/sales/{id}`, `/{id}/items` and `/{id}/payments`. Every row also carries `source` as upstream provenance — `"mbo"` today for every synced row (ds_enriched v2's provenance column, see docs/DS-ENRICHED-V2.md). It exists so a future second raw source (e.g. a Momence/Arketa mirror) can land in the same tables without growing a parallel set of prefixed columns; `"pending_write"` is this API's own overlay value layered on top of that column, not a competing source.

Parameter Required Type Allowed values
client_id (query) No string
date_from (query) No string (date)
date_to (query) No string (date)
location_id (query) No string
modified_since (query) No string (date-time)
adjusted (query) No boolean NOT IMPLEMENTED — 400s
GET /v1/sales/items Datastream

Line items for multiple sales in one call (batch)

Batched form of `/v1/sales/{sale_id}/items` — pass `sale_ids` as a comma-separated list (same convention as every other multi-value param) and get every matching line back in one flat list, one `WHERE sale_id IN (...)` query instead of one request per sale. Built for purchase-history style pages that otherwise fire two requests per sale after listing them. Registered ahead of `/v1/sales/{sale_id}` so the literal path `items` is not swallowed by that route's `sale_id` parameter.

Parameter Required Type Allowed values
sale_ids (query) Yes string Comma-separated sale ids
GET /v1/sales/payments Datastream

Payments for multiple sales in one call (batch)

Batched form of `/v1/sales/{sale_id}/payments` — same `sale_ids` convention and same IN (...) shape as `/v1/sales/items`.

Parameter Required Type Allowed values
sale_ids (query) Yes string Comma-separated sale ids
GET /v1/sales/{sale_id} Datastream

One sale header

The transaction record itself (date, client, location, totals) from the enriched layer — line items and payments are separate reads (`/v1/sales/{sale_id}/items`, `/payments`), joined by this same `sale_id`.

Parameter Required Type Allowed values
sale_id (path) Yes string
GET /v1/sales/{sale_id}/items Datastream

Line items of a sale (revenue earned)

Never sum together with /payments — the same money appears in both. Returns carry a negative `unit_price` AND a negative `item_quantity`: MBO puts the sign in both columns, so the naive `unit_price * item_quantity` flips a refund back to positive. Extended list price is `unit_price * ABS(item_quantity)`. `total_amount` is already signed and net of discount, so a purchase-history total is simply `SUM(total_amount)`; the discount is the gap between that and the extended list price. Verified against MBO's ALL Purchases PDF. Each row carries `source` (`ds_enriched.sale_items.source`, default `"mbo"`) — provenance, same convention as `/v1/sales`.

Parameter Required Type Allowed values
sale_id (path) Yes string
GET /v1/sales/{sale_id}/payments Datastream

Payments of a sale (money received)

Never sum together with /items — the same money appears in both. The two method fields read backwards from what the names suggest, and this is MBO's naming carried through verbatim: `payment_type` is the human label MBO prints on its reports ("Square", "Account", "Credit Card", "Comp/Guest"); `payment_method` is the numeric MBO code behind it ("19", "16", "4", "7"). Display `payment_type`; match on `payment_method`. Each row carries `source` (`ds_enriched.sale_payments.source`, default `"mbo"`) — provenance, same convention as `/v1/sales`.

Parameter Required Type Allowed values
sale_id (path) Yes string
Datastream · packages 9
POST /v1/contracts/{contract_id}/purchases Datastream

Sell a membership to a client

Buys a contract (membership/autopay) for a client via Mindbody's `purchasecontract`. Requires the `contract:write` scope — deliberately separate from `purchase:write`, because a membership creates a recurring billing obligation rather than a one-off charge. **Mindbody has no dry run for this endpoint**, and it silently ignores unknown fields — verified against production on 2026-08-02: `Test: true` and a deliberately bogus field produced byte-identical responses. So `test` is never forwarded. `test: true` (**the default**) runs a local preflight instead: it reads the contract, checks it is purchasable at the location Mindbody would check against, and returns the terms and the amounts that would be charged, with `validated_locally_only: true`. It cannot tell you whether the card will authorise or whether the studio's own rules allow the sale — only a real purchase does that. A real purchase (`test: false`) additionally requires **`confirm_amount`**, the first payment the caller expects to charge. It must match the catalog or the request is a `409`. There is no dry run and the amount is otherwise absent from the request, so this is what stops a consumer whose catalog has drifted from silently charging the wrong figure. **Payment sources:** `use_account_credit` (draws on the client's Mindbody account balance) or `stored_card` (charges the card on file). Raw card details are refused with a `400` — accepting a PAN would place this service in PCI DSS scope. `use_direct_debit` is refused too: Mindbody answers `Direct debit not enabled` for this site. Only a stale staff token is retried. The purchase itself never is — a retry Mindbody accepted the first time would enrol the client twice. Send an `Idempotency-Key`.

Parameter Required Type Allowed values
contract_id (path) Yes string
Idempotency-Key (header) No string Opaque, caller-chosen, unique per intended booking. A repeat within 24 hours replays the first response and sets `Idempotency-Replayed: true` rather than booking again. Failures are not recorded, so a key frees up for a genuine retry. Scoped to the API key.
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/packages Datastream

Purchasable services and packages

Backed by `ds_mbo.service`. Hides discontinued packages unless `discontinued=true`. The legacy `location_id` filter is deliberately not implemented — it would require matching inside the `sellAtLocationIds` JSON array, whose element type is unverified, and a filter that quietly matches nothing is worse than an absent one. `location_ids` is returned on every row for client-side filtering. **Not yet populated:** `description`, `is_auto_renewing`.

Parameter Required Type Allowed values
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
sell_online (query) No boolean
is_intro_offer (query) No boolean
discontinued (query) No boolean
type (query) No string
modified_since (query) No string (date-time) Return only rows the mirror updated at or after this instant.
limit (query) No integer
offset (query) No integer
GET /v1/contracts Datastream

The contract catalog — memberships and autopays a client can buy

ds_mbo.sale_contract: what is FOR SALE, as opposed to /v1/clients/{client_id}/contracts, which is what a client HOLDS. MBO returns contracts per location, so a contract sold at three studios appears three times, each row carrying its own `location_id`. Pricing is quoted as `first_payment_total`, `recurring_payment_total` and `total_contract_amount`; `contract_items` carries the line items the contract grants. `description` is not included in this list response — it was 69.5KB of a 214KB payload (31%) across 174 rows. Fetch it on demand from `GET /v1/contracts/{contract_id}/description` rather than expecting it inline. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
sold_online (query) No boolean
is_intro_offer (query) No boolean
autopay_enabled (query) No boolean
location_id (query) No string
modified_since (query) No string (date-time)
GET /v1/contracts/{contract_id} Datastream

One contract from the catalog

The sellable membership definition itself (terms, pricing, autopay schedule) — not a client's holding of it. For what a specific client actually has, see `/v1/clients/{client_id}/contracts`. Includes `description`, unlike the list response.

Parameter Required Type Allowed values
contract_id (path) Yes string
GET /v1/contracts/{contract_id}/description Datastream

One contract's description

The contract name and full HTML description, keyed by `contract_id` — the same field every `/v1/contracts` row carries. Split out of `/v1/contracts` because the description text was ~31% of that endpoint's payload by weight; this route is meant to be fetched on demand (e.g. when a user expands a contract for details) and cached long by the client, not called once per list row.

Parameter Required Type Allowed values
contract_id (path) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/products Datastream

The retail catalog — merchandise, not class packs

`ds_mbo.product`: water bottles, apparel, cacao, gift cards. Distinct from `/v1/packages`, which is `ds_mbo.service` (class packs and drop-ins). MBO's `/sale/products` was not pulled until 2026-08-01, so this is empty until the sync's next SalesFamily run — an empty list here means "not synced yet", not "no retail". Every field except `product_id` and `site_id` is currently unverified against real rows; see `/status/schema-assumptions`. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
category_id (query) No string
sell_online (query) No boolean
discontinued (query) No boolean
modified_since (query) No string (date-time)
GET /v1/products/{product_id} Datastream

One retail product

A single physical/retail item from the catalog (mats, apparel, gift cards) — not a class package or membership contract.

Parameter Required Type Allowed values
product_id (path) Yes string
GET /v1/promo-codes Datastream

Promo code definitions and their terms

ds_mbo.promo_code — the codes that exist and what they do: `discount_type` ("Percent" or "FlatRate") with `discount_amount`, the `activation_date`/`expiration_date` window, `days_valid`, `max_uses`, and `applicable_items`. Definitions only. Datastream records the discount a sale received (`sale_items.discount_amount`) but not which code produced it, and MBO's sale payload has no promo field — so redemption counts are not available from this API at any endpoint. `current=true` is the practical filter: active AND inside its date window. Most expired codes are still flagged active in MBO, so `active=true` alone over-reports what a student could actually redeem. Open-ended codes carry an expiration of 2099-12-31. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
code (query) No string Exact match
current (query) No boolean Active and within its date window
active (query) No boolean
allow_online (query) No boolean
discount_type (query) No string Percent, FlatRate
modified_since (query) No string (date-time)
GET /v1/promo-codes/{promo_code_id} Datastream

One promo code

Discount code definition — percent/amount off, validity window, applicable services. Redemption is tracked on the sale, not here.

Parameter Required Type Allowed values
promo_code_id (path) Yes string
Datastream · staff 2
GET /v1/staff Datastream

Staff and instructors

Backed by `ds_mbo.staff`. **Not yet populated:** `slug`. The mirror's staff entity does not carry it — the legacy API read it from `mb_flow_staff` on the warehouse. **`bio` is not returned here.** It is a long free-text biography that was ~62% of this endpoint's payload by weight at `limit=1000`; fetch it on demand from `GET /v1/staff/{staff_id}/bio` instead.

Parameter Required Type Allowed values
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
location_id (query) No string
active (query) No boolean
class_teacher (query) No boolean Restrict to staff who teach classes.
modified_since (query) No string (date-time) Return only rows the mirror updated at or after this instant.
limit (query) No integer
offset (query) No integer
GET /v1/staff/{staff_id}/bio Datastream

One staff member's biography

A teacher's full-text biography, keyed by `staff_id` — the same field every `/v1/staff` row carries. Split out of `/v1/staff` because `bio` was ~62% of that endpoint's payload by weight at `limit=1000`; this route is meant to be fetched on demand (e.g. when a user expands a teacher profile) and cached long by the client, not called once per staff row.

Parameter Required Type Allowed values
staff_id (path) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
Datastream · locations 2
GET /v1/locations Datastream

Studio locations

Backed by `ds_mbo.location`. PLAN.md §6 listed `ds_config` as the source, but `ds_config.sites` is the tenant registry and has no addresses; the mirror holds the data matching this shape. Requires `raw:read`. **Not yet populated:** `slug`, `image_url`. `amenities` passes through MBO's array of objects rather than the array of strings the legacy schema advertised.

Parameter Required Type Allowed values
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
has_classes (query) No boolean
modified_since (query) No string (date-time) Return only rows the mirror updated at or after this instant.
limit (query) No integer
offset (query) No integer
GET /v1/rooms Datastream

Bookable rooms at the site

`ds_mbo.resource` — `{room_id, name}`. MBO returns nothing else on `/site/resources`: **no capacity and no location**. Room capacity lives in the Portal's own `cms_rooms_capacity`, and the room↔location pairing is only observable through classes, which carry both. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
modified_since (query) No string (date-time)
Datastream · events 3
GET /v1/events Datastream

Enrollments, workshops, and events

Backed by `ds_mbo.enrollment`. **Not yet populated:** `max_capacity`, `total_booked` — the enrollment entity has no counts, and the legacy API aggregated them warehouse-side. `pricing` is not served at all: it came from `cms_pricing` on the Portal, which is another product's schema (CLAUDE.md rule 5).

Parameter Required Type Allowed values
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
start_date (query) No string (date) Events overlapping on or after this date.
end_date (query) No string (date)
location_id (query) No string
category (query) No string Style of the description ("Yoga", "Meditation"), not the kind of event.
class_description_id (query) No string All scheduled dates for one class description — i.e. every occurrence of a single Publisher post. Note `flow_cms_posts.mbo_enrollment_id` stores this id, not `event_id`.
session_type (query) No string MBO's own label — Event, Community, Workshop, Retreat, Teacher Trainings, ...
program (query) No string MBO program the description is filed under — Event Single-Day, Workshops, Retreats, Teacher Trainings, ...
event_type (query) No string event, training, retreat
modified_since (query) No string (date-time) Return only rows the mirror updated at or after this instant.
limit (query) No integer
offset (query) No integer
GET /v1/events/{event_id} Datastream

One event

A single enrollment — a training, retreat, or workshop by MBO `event_id`. An event is a date RANGE, not one class instance: a mentorship can run May → January as one row, so this shows the span rather than pretending it's a single session.

Parameter Required Type Allowed values
event_id (path) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
GET /v1/events/detail Datastream

One event (legacy query-param form)

Same event lookup as `/v1/events/{event_id}`, with `event_id` as a query param instead of a path segment. Kept for callers that haven't repointed yet.

Parameter Required Type Allowed values
event_id (query) Yes string
site_id (query) No string Comma-separated 32-character Datastream site ids. Narrows *within* the key's own sites; anything outside them is a 403. This is not the MBO numeric site id.
Datastream · reports 6
GET /v1/reports/sales-daily Datastream

Daily revenue by location and category (cash-accounting adjusted)

Replaces ai_sales_snapshot_v2 — specifically dw_flow's vw__dm_sales_adjusted shape, which is what the old Redash dashboards actually read. Sums ds_enriched.sale_items (excluding line items literally named "Tip") and unions a negated slice of ds_enriched.sale_payments for non-cash payment types (Groupon, ClassPass, Debt Write Off, LivingSocial, Comp/Guest, Flow Yoga, Gift Card, Account) — those sales show full retail price on the purchased item but no real cash came in, so the payment leg offsets it back out. Excludes ds_enriched.config_excluded_clients (test/demo accounts). Requires `date_from`/`date_to` in practice — an unbounded call can time out over the full sales history.

Parameter Required Type Allowed values
date_from (query) No string (date)
date_to (query) No string (date)
location_id (query) No string
GET /v1/reports/membership-changes Datastream

Point-in-time active membership counts by type and location

Replaces ai_membership_snapshot_v2's membership-by-type chart (dw_flow.pc_memberByType). For each client, ranks their ds_enriched.client_contracts rows by start_date/agreement_date and keeps only the most recent contract that is active/unexpired/ non-terminated as of `as_of` (default today) — a true point-in-time snapshot, not a raw count of every contract row started in a date range. Excludes config_excluded_clients. `changeInMembership` (new/renewed/cancelled/expired, diffed day-over-day by the old cron against yesterday's snapshot) has no equivalent — there is no daily snapshot history table here to diff against — and is intentionally omitted.

Parameter Required Type Allowed values
as_of (query) No string (date) Snapshot date
location_id (query) No string
GET /v1/reports/class-performance Datastream

Class attendance and capacity rolled up by class/teacher/day-time

Replaces ai_class_performance_snapshot_v2 + ai_class_detail_snapshot_v2 in one shape, from ds_enriched.classes. "Best"/"worst" rankings (best class times, top candidates for replacement) are this same rollup sorted ascending instead of descending on avgClassAttendance — there is nothing further to compute. Not excluded: config_excluded_clients. classes.total_signed_in is a count at the class-occurrence grain (no client_id column on this table), so a test account's visit can't be excluded here without a per-class fan-out join down to class_visit.

Parameter Required Type Allowed values
date_from (query) No string (date)
date_to (query) No string (date)
location_id (query) No string
GET /v1/reports/new-students Datastream

New students by first signed-in visit within a required date window

Replaces the dw_flow first-visit marts (vw_first_visits / the v1 client_first_visits table), which were removed in the v2 enriched rebuild (docs/DS-ENRICHED-V2.md, "Gone in v2"). Computed live from the raw mirror ds_mbo.class_visit: ranks every signed-in visit per client by start time within the requested window, keeps rank 1, groups by the date of that first visit. Excludes config_excluded_clients. `date_from`/`date_to` are REQUIRED — 400 without both. "New student" means *first signed-in visit within the queried window*, not an absolute lifetime-first-visit-ever: a client whose true first visit predates date_from but who visits again inside the window will show up as a "new student" on that later date. An earlier version ranked each client's entire visit history to preserve a true lifetime-first answer and filtered the date window only after ranking; that 503'd in production (class_visit is 897K+ rows) even with a supporting index, because the volume of history scanned per client — not just the sort — was the actual cost. This is a deliberate, documented tradeoff, and matches how comparable "new users in period" reports work elsewhere. `location_id`, if given, narrows the ranking window further — a client's first signed-in visit *at that location, within the date window*.

Parameter Required Type Allowed values
date_from (query) Yes string (date)
date_to (query) Yes string (date)
location_id (query) No string
GET /v1/reports/promo-detail Datastream

Promo code catalog and discounted sale line items (unjoined)

Replaces ai_promo_detail_snapshot_v2. Datastream does not tie a sale to the promo code that discounted it — sale_items only carries discount_amount, no code reference, and MBO's sale payload itself has no promo field to mirror. So this returns two independent lists: the promo code catalog (ds_mbo.promo_code) and sale_items rows with a discount in the date window (config_excluded_clients-filtered) — not a fabricated join. A caller who needs "how much did FREEWEEK cost us" cannot get that from Datastream today.

Parameter Required Type Allowed values
date_from (query) No string (date)
date_to (query) No string (date)
GET /v1/reports/mbo-usage Datastream

Mindbody API call volume — sync side and write side

Requires `config:read` (ops data, not client data). Two sources, tagged by `source`, one row per (date, site, family|endpoint): - `sync` — itflow_datastream's scheduled and webhook-driven pulls, read from `ds_config.sync_control_execution` (that service's own schema, read-only here). `family` is a sync family (`ClassFamily`, `TransactionFamily`, …); `endpoint` is null. - `write_api` — this service's own writes (bookings, cancels, check-ins, schedule/teacher edits), counted at the single choke point in `mboFetch()` and stored in `ds_api.mbo_call_log`. `endpoint` is the MBO path (e.g. `/class/addclienttoclass`); `family` is null. Defaults to the last 7 days if neither `date_from` nor `date_to` is given — this is a spend dashboard, not a full-history report. **Not covered:** the legacy Java sync service on AWS, which also calls Mindbody and is not instrumented anywhere this API can read. That spend has to be read from Mindbody's own account dashboard, not this endpoint — the response `note` field repeats this so a chart never implies otherwise. **Degrades gracefully** if `ds_api.mbo_call_log` (sql/019) hasn't been applied yet: the response falls back to sync-only rows rather than erroring.

Parameter Required Type Allowed values
date_from (query) No string (date)
date_to (query) No string (date)
Datastream · config 1
GET /v1/sites Datastream

Tenant registry for this key

Backed by `ds_config.sites`, scoped to the key's own sites. Requires `config:read`. An empty response does not mean "no such site": two data-bearing site ids are absent from the registry entirely, so a key can be bound to a site that has data but no registry row.

Parameter Required Type Allowed values
limit (query) No integer
offset (query) No integer
Datastream · clients 7
GET /v1/clients Datastream

Look up clients by id, unique id, email, status, or modified_since

Client profile rows: `client_id`, name, `email`, `mobile_phone`, `status` / `active`, `account_balance`, `creation_date`, `home_location_id`, `address`, `address2`, `city`, `state`, `postal_code`, `country`, etc. Requires at least one narrowing filter — an unfiltered call is a full per-site dump and returns 400. `email` resolves through the enriched layer's indexed email column, then serves the fresh mirror row; an email registered since the last enriched refresh will not resolve. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
client_id (query) No string
client_unique_id (query) No string
email (query) No string
status (query) No string
modified_since (query) No string (date-time)
site_id (query) No string Narrow within the key's site scope
limit (query) No integer
offset (query) No integer
GET /v1/clients/{client_id}/enrollments Datastream

Bookings — upcoming, or a window of visit history

Defaults to start_time >= now. `include_past=true` lifts the floor; `date_from`/`date_to` set an explicit window instead and suppress the default. Joined to class/description/staff/location for display names. For visit history, prefer a window: results are ordered by start time ASC and capped at limit=1000, so `include_past=true` on a long-standing member returns their OLDEST page of visits, not their most recent. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
client_id (path) Yes string
include_past (query) No boolean
date_from (query) No string (date)
date_to (query) No string (date)
GET /v1/clients/{client_id}/credits Datastream

Active passes with remaining balance ("My Passes & Credits")

Current services with an unexpired window; `remaining_count` / `total_count` answer "7 of 10 classes left, expires June 15". Subset of `/services` useful for a profile "active credits" strip.

Parameter Required Type Allowed values
client_id (path) Yes string
expires_after (query) No string (date)
GET /v1/clients/{client_id}/services Datastream

All service/pass records for a client (raw, incl. expired)

Every client_service row: packs, unlimited periods, intro offers. `is_current` splits active vs past; includes `service_name`, `remaining_count`, `total_count`, `active_date`, `expiration_date`, `program_name`. Reads from `ds_enriched.client_services` (cut over 2026-08-04); carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
client_id (path) Yes string
is_current (query) No boolean
active_from (query) No string (date)
GET /v1/clients/{client_id}/contracts Datastream

Contracts / autopay memberships for a client

What the client **holds** (not the sellable catalog at `/v1/contracts`). Fields include `contract_name`, `autopay_status`, `agreement_date`, `start_date`, `end_date`. Use autopay_status / end_date to separate active memberships from past ones. Reads from `ds_enriched.client_contracts` (cut over 2026-08-04; memberships are merged into this table upstream); carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
client_id (path) Yes string
autopay_status (query) No string
start_from (query) No string (date)
GET /v1/clients/{client_id}/transactions Datastream

Payment transaction history for a client

Card charges for the client: `amount`, `status`, `transaction_time`, `card_type`, `cc_last_four`, `is_settled`. Card expiration month/year are deliberately NOT mapped anywhere in this service and can never appear. Reads from `ds_mbo.client_transaction`; carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).

Parameter Required Type Allowed values
client_id (path) Yes string
auth_from (query) No string (date-time)
auth_to (query) No string (date-time)
CMS · ops 2
GET /status CMS

Liveness and database connectivity

Unauthenticated. Confirms the pool can run a query against cms-pg-prd — not a check of any individual sync job's freshness (see the per-schema sync_state tables for that).

GET /v1/docs CMS

This OpenAPI document (JSON)

Served as JSON (datastream-api's equivalent serves YAML) — newapi's combined API docs page (/) fetches both and renders one operation list across both products.

CMS · config 1
GET /v1/tenants/me CMS

The tenant this key belongs to, with its sites and origins

Reflexive by design — a key resolves to exactly one tenant, so this is not a directory. Returns tenant, sites, origins, and the calling key's scopes.

CMS · contacts 6
GET /v1/contacts CMS

Find contacts

Free-text `q` dispatches on shape: UUID → contact_id, all digits → MBO client id, contains `@` → email, otherwise name substring. Without `q`, at least one of `email`, `external_id`, `marketable`, or `modified_since` is required — an unfiltered scan of ~100k+ contacts is rejected. `external_id` resolves through `contact_identity` and requires `id_type`, because ids from different sources collide.

Parameter Required Type Allowed values
q (query) No string Free-text: uuid, MBO client id, email, or name.
email (query) No string Case-insensitive. May return several people — family accounts share an address.
external_id (query) No string
id_type (query) No string mbo_client, hubspot_contact, tracking_visitor, email
site_id (query) No string Disambiguates an MBO client id across sites (32-char hex).
marketable (query) No boolean
modified_since (query) No string (date-time)
include_duplicates (query) No boolean Include contacts merged into another.
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/contacts/{contact_id} CMS

A single contact

The contact record identity resolution settled on — marketable status, lifecycle stage, matched-by info. Everything downstream (the profile endpoint's HubSpot/tracking/consent sections) keys off this one contact_id, not the raw MBO client id or email.

Parameter Required Type Allowed values
contact_id (path) Yes string Contact UUID, or an MBO client id. A client id that exists at two of the tenant's sites is ambiguous and returns 400 — pass `site_id`.
site_id (query) No string Disambiguates an MBO client id across sites (32-char hex).
GET /v1/contacts/{contact_id}/identities CMS

Every external id resolving to this contact

The crosswalk: every mbo_client → hubspot_contact → tracking_visitor → email identifier this contact absorbed during resolution, each with its source and first-seen date. When a resolution looks wrong — two people merged, or a match that shouldn't have happened — this is the table that explains why, since resolution order is mbo_client → hubspot_contact → tracking_visitor → email (email is a last resort; 27,096 contacts share an address with another contact).

Parameter Required Type Allowed values
contact_id (path) Yes string Contact UUID, or an MBO client id. A client id that exists at two of the tenant's sites is ambiguous and returns 400 — pass `site_id`.
site_id (query) No string Disambiguates an MBO client id across sites (32-char hex).
GET /v1/contacts/{contact_id}/visits CMS

This person's page views

Matched via: 1. `tracking_visitor` identities (cookie stitch), and/or 2. `(site_id, client_id)` on the visit matching an `mbo_client` identity (HubSpot page-visit backfill and identified tracker hits). **Single-source by default.** `source` defaults to `blended`, which serves each instant from exactly one feed: HubSpot page-view history before the tenant's first tracker hit, the tracker from there on. HubSpot rows at or after that cutover are validation data (see `/v1/visits/reconciliation`) and are left out, so a total can never double-count the handoff. `source=all` returns the raw union, `source=hubspot` both HubSpot feeds, and a single source key just that feed. Newest first.

Parameter Required Type Allowed values
contact_id (path) Yes string Contact UUID, or an MBO client id. A client id that exists at two of the tenant's sites is ambiguous and returns 400 — pass `site_id`.
site_id (query) No string Disambiguates an MBO client id across sites (32-char hex).
since (query) No string (date-time)
until (query) No string (date-time)
source (query) No string blended, all, hubspot, tracker, hubspot_backfill, hubspot_import
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/contacts/{contact_id}/profile CMS

Full profile for one person

Everything this platform holds on one contact in one response: contact row, identities, consent, tracking summary (by source + recent pages), HubSpot properties (human-written fields when matched), and optional live Datastream operational data when configured.

Parameter Required Type Allowed values
contact_id (path) Yes string Contact UUID, or an MBO client id. A client id that exists at two of the tenant's sites is ambiguous and returns 400 — pass `site_id`.
site_id (query) No string Disambiguates an MBO client id across sites (32-char hex).
skip_datastream (query) No boolean Skip the live Datastream HTTP call.
GET /v1/contacts/{contact_id}/activity CMS

Marketing/web activity feed for one contact

Chronological Funnel events for a person timeline (newest first): | kind | Source | |---|---| | `page_view` | `funnel_tracking.page_visit` (`status` / `meta.source`: `tracker`, `hubspot_backfill`, …) | | `form_submission` | HubSpot form submissions by contact email | | `chat` | Conversations threads (web chat channel) | | `sms_thread` | Conversations SMS threads | | `sms_campaign` | Outbound CRM marketing SMS | | `email_sent` | Marketing email SENT (HubSpot `/email/public/v1/events`) | | `email_open` / `email_click` / `email_bounce` | Same events API | **Email delivery is not its own row.** HubSpot `DELIVERED` is folded into the matching `email_sent` as `status: "Delivered"` (or `"Bounced"`) via `email_campaign_id`. A DELIVERED with no SENT in the window still emits one `email_sent` with status Delivered. Studio class visits and purchases stay on Datastream (`/v1/clients/…/enrollments`, `/v1/sales`). Linked via MBO client → contact, then HubSpot contact ids (identity or email match on crm_object, including merged vids). Email events match on contact email (`recipient`). Engagement rows are loaded by cron into `funnel_hubspot.email_event` (not live HubSpot at request time).

Parameter Required Type Allowed values
contact_id (path) Yes string Contact UUID, or an MBO client id. A client id that exists at two of the tenant's sites is ambiguous and returns 400 — pass `site_id`.
site_id (query) No string Disambiguates an MBO client id across sites (32-char hex).
since (query) No string (date-time)
until (query) No string (date-time)
limit (query) No integer
CMS · visits 2
GET /v1/visits/coverage CMS

What each website-history feed covers, and where they hand off

Website history arrives from two feeds — HubSpot's page-view export (`hubspot_backfill`, `hubspot_import`) and our own cookie (`tracker`) — and HubSpot is being cancelled. This reports each feed's first and last hit, the `blend_cutover` a blended read uses, the `overlap` window where a tracker-vs-HubSpot comparison is possible, and any `gap` neither feed covered. Read this before summing anything. Measured for tenant `flow` on 2026-08-04: HubSpot 2025-04-14 → 2026-01-02 (601,978 rows) then a 382-row trickle to 2026-04-01, tracker 2026-04-02 → now (787k rows and counting) — contiguous, with no overlap yet.

GET /v1/visits/reconciliation CMS

Which students each feed identified, tracker versus HubSpot

The validation pass to run while HubSpot is still paid for — and it compares **students, not page-view volume**. Volume cannot settle this: HubSpot only recorded views for contacts it knew, the tracker also sees anonymous traffic, bots and crawlers hit the site constantly, and the front-desk kiosk concentrates many people onto a few identities (measured July 2026: 29 `client_id`s absorbed 9,295 mbocheckin.com views). Each bucket therefore counts distinct Mindbody `client_id`s: `both_students`, `tracker_only_students`, and the one that matters, `hubspot_only_students` — people HubSpot tied to a visit and we did not. `coverage_pct` is both / hubspot_students; `missed_pct` its complement. Measured over the full Apr–Aug 2026 overlap: coverage 85–86% every month, so ~600 students a month are attributed by HubSpot and not by us. Of July's 618, 253 arrived with `utm_medium=email` — HubSpot knew them from an email click, which our cookie cannot see unless they then log in or book. `verdict`: `no_overlap` (no bucket has both feeds — untested, not passing), `match`, `drift`, or `not_assessed` for `metrics=visits`. Drift is one-sided on purpose: identifying more people than HubSpot is not drift.

Parameter Required Type Allowed values
date_from (query) No string (date-time)
date_to (query) No string (date-time)
grain (query) No string day, week, month
metrics (query) No string students, visits
tolerance_pct (query) No number Missed-student share above which a bucket counts as drift. Default 10.
CMS · conversations 3
GET /v1/conversations CMS

Inbox threads (newest first)

HubSpot Conversations threads (chat, email, SMS channel, etc.). Use `channel_id` / `channel_ids` for multi-select. Full-text `q` searches subject and message bodies.

Parameter Required Type Allowed values
status (query) No string e.g. OPEN, CLOSED
contact_id (query) No string HubSpot associated contact id
channel_id (query) No string Single channel or comma list
channel_ids (query) No string Alias for multi channel_id
q (query) No string Search subject / message body
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/conversations/{thread_id} CMS

One conversation thread

HubSpot Conversations metadata for one thread — status (open/closed), channel (live chat = 1000, email = 1002, SMS = 1009, etc.), assigned agent. Message bodies are the separate `/messages` sub-resource.

Parameter Required Type Allowed values
thread_id (path) Yes string
GET /v1/conversations/{thread_id}/messages CMS

Messages in a thread (chronological)

Every message in the thread, oldest first — type=MESSAGE only (system/bot events are filtered out). An empty text_body usually means an attachment, not a blank message.

Parameter Required Type Allowed values
thread_id (path) Yes string
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
CMS · sms 4
GET /v1/sms CMS

Outbound marketing SMS (CRM Communications)

Default is marketing/campaign sends (`source=CRM`, `view=sends`). - `view=blasts` — group similar copy into blasts - `source=CONVERSATIONS` — legacy list of channel-1009 threads (prefer `/v1/conversations` for two-way SMS)

Parameter Required Type Allowed values
source (query) No string CRM, MARKETING, CAMPAIGN, CONVERSATIONS
view (query) No string sends, blasts
contact_id (query) No string
q (query) No string
status (query) No string
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/sms/blasts/{blast_id} CMS

One blast + recipient sends

A "blast" is not a HubSpot object — it's this API's own grouping of individual CRM-logged sends that share the same message template (keyed by an md5 of the template text, taken from the list view's blast_id). Returns the shared copy plus every recipient send that matched it.

Parameter Required Type Allowed values
blast_id (path) Yes string md5 template key from list view
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/sms/crm/{hs_object_id} CMS

One outbound marketing SMS by HubSpot object id

A single individual CRM-logged text (CRM Communications API, channel_type=SMS) — the "Sends" view's detail, not a Marketing SMS campaign asset (see `/v1/hubspot/sms-campaigns` for those).

Parameter Required Type Allowed values
hs_object_id (path) Yes string
GET /v1/sms/threads/{thread_id} CMS

Legacy conversation-style SMS thread detail

Predates the Conversations integration — kept only for callers still pointed at it. Prefer `/v1/conversations/{thread_id}/messages` for channel 1009 (SMS); it's the same underlying thread, read through the current, actively-maintained path.

Parameter Required Type Allowed values
thread_id (path) Yes string
CMS · hubspot 13
GET /v1/hubspot/lists CMS

HubSpot lists / segments (export)

Deliberately not `/v1/segments` (reserved for platform-owned rules). Per list: `hubspot_size` (what HubSpot reported) vs `member_count` (rows exported). They diverge when a list was recalculating mid-export.

Parameter Required Type Allowed values
q (query) No string Substring match on list name
processing_type (query) No string DYNAMIC, SNAPSHOT, MANUAL
object_type (query) No string contacts, companies, deals, tickets
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/hubspot/lists/{list_id} CMS

One list + flattened filters

`filters` are leaves with `group_label` / `group_operator` for AND/OR nesting. `filter_branch` is the raw HubSpot tree.

Parameter Required Type Allowed values
list_id (path) Yes string
GET /v1/hubspot/lists/{list_id}/members CMS

Members of a list

Stored as HubSpot record ids; names/emails/MBO joined at read time. Null `contact_id` = not yet crosswalked into the spine.

Parameter Required Type Allowed values
list_id (path) Yes string
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/hubspot/forms CMS

HubSpot form definitions

The forms themselves — name, field list, submission/view counts — not the individual responses people typed in. See the `/submissions` sub-resource for those.

Parameter Required Type Allowed values
q (query) No string
published (query) No string true, false
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/hubspot/forms/{form_id} CMS

One form (fields + metadata)

One form's full field list (label, name, type, required/hidden) alongside its submission and view counts.

Parameter Required Type Allowed values
form_id (path) Yes string
GET /v1/hubspot/forms/{form_id}/submissions CMS

Submissions for a form

Every response to one form, newest first — the field values a visitor actually typed, plus which contact (if any) it resolved to.

Parameter Required Type Allowed values
form_id (path) Yes string
q (query) No string
email (query) No string
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/hubspot/forms/{form_id}/submissions/{conversion_id} CMS

One form submission

A single response by its HubSpot conversion_id — full field-by-field answers for that one submission.

Parameter Required Type Allowed values
form_id (path) Yes string
conversion_id (path) Yes string
GET /v1/hubspot/emails CMS

Marketing email campaigns

From `funnel_hubspot.asset` kind `marketing_email` (campaign definitions + rollup stats). Per-recipient send/open/click history is **not** listed here — use `GET /v1/contacts/{id}/activity` (`email_*` kinds from `funnel_hubspot.email_event`, filled by the HubSpot email-events sync). Default console filter is `published=true` (batch + automated + AB), not only `state=PUBLISHED`.

Parameter Required Type Allowed values
q (query) No string Name, id, subject, campaign
state (query) No string Exact HubSpot state e.g. PUBLISHED, DRAFT, AUTOMATED
published (query) No string true, false
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/hubspot/emails/{email_id} CMS

One marketing email + HTML body preview

Includes performance stats and `body_html` reconstructed from HubSpot content widgets (module HTML, images, buttons, styleSettings).

Parameter Required Type Allowed values
email_id (path) Yes string
GET /v1/hubspot/ctas CMS

HubSpot CTAs (static UI export)

Not available on a public API we can cron. Loaded from a one-shot parse of the HubSpot CTAs listing (`scripts/load-hubspot-ctas.ts`).

Parameter Required Type Allowed values
q (query) No string
status (query) No string Published or Draft
type (query) No string Pop-up, Banner, Embedded, …
published (query) No string true, false
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/hubspot/ctas/{cta_id} CMS

One CTA

One HubSpot call-to-action's full record — type, image, views, submissions, submission rate. Sourced from a one-shot UI export (`funnel_hubspot.asset` kind=cta), not a live API sync — CTAs aren't reachable with our current private-app scopes.

Parameter Required Type Allowed values
cta_id (path) Yes string
GET /v1/hubspot/sms-campaigns CMS

HubSpot Marketing SMS campaigns

Real Marketing SMS campaign assets — name, delivered count, click rate, publish/send date, created-by — a different HubSpot object from `/v1/sms` (which covers individual CRM-logged texts). Not reachable via a live API sync: our private-app token lacks the Marketing SMS scope, so this is a one-shot UI export landed in `funnel_hubspot.asset` (kind=marketing_sms), same pattern as CTAs.

Parameter Required Type Allowed values
q (query) No string Matches name or hs_id
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/hubspot/sms-campaigns/{sms_campaign_id} CMS

One Marketing SMS campaign

Full record for one campaign, including the raw exported document.

Parameter Required Type Allowed values
sms_campaign_id (path) Yes string
CMS · cloudtalk 3
GET /v1/cloudtalk/calls CMS

CloudTalk call history

Every call (incoming/outgoing, answered/missed/voicemail) synced from CloudTalk's own REST API into `funnel_cloudtalk.call`. "Missed" is derived at sync time, not read live: CloudTalk's IVR auto-answers every call, so `answered_at` is set even when no human picked up — the real signal is talking_time = 0 on an incoming call. `status` here is already resolved to one of answered / missed / voicemail.

Parameter Required Type Allowed values
q (query) No string Matches the other party's number or contact name
type (query) No string incoming, outgoing
status (query) No string answered, missed, voicemail
location (query) No string Studio line label, e.g. "Cedar Park"
date_from (query) No string (date-time)
date_to (query) No string (date-time)
contact_id (query) No string CloudTalk contact id
limit (query) No integer Page size, 1–1000. Default 50.
offset (query) No integer
GET /v1/cloudtalk/calls/{call_id} CMS

One call

Full detail for one call — both parties' numbers, studio line, agent, duration, waiting time, recording link — including the raw synced document.

Parameter Required Type Allowed values
call_id (path) Yes string
GET /v1/cloudtalk/calls/{call_id}/recording CMS

Stream a call's recording audio

Proxies CloudTalk's undocumented `/calls/recording/{id}.json` endpoint (the dashboard's own `recording_link` 301s to a cookie-gated SPA page, not raw audio) and streams the `audio/wav` bytes back directly.

Parameter Required Type Allowed values
call_id (path) Yes string