Datastream and CMS API
Datastream · ops 4
/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.
/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.
/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.
/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 23
/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. |
class_schedule_id (query) |
No | string |
The recurring schedule an instance belongs to — **and the join back to `/v1/events`.** An event's `event_id` and a class instance's `class_schedule_id` are the same id, so this answers "which bookable class is this event", which is the only route from an event to a `class_id`. **The date window does not apply when this is set.** A caller holding an `event_id` does not know when its occurrences fall, which is the reason it is asking — so requiring dates alongside it would defeat the lookup. Explicit `start_date`/`end_date` (or `date`/`days`) still apply if you pass them, and are worth passing for a long-running weekly schedule, which can have years of instances and would otherwise return its oldest page first. |
class_schedule_ids (query) |
No | string |
Comma-separated batch form of `class_schedule_id`, so an events listing resolves every bookable class id in one request instead of one per event. Maximum 100. Mutually exclusive with `class_schedule_id` — passing both is a 400 rather than a silent preference. |
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. |
/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. |
/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. |
/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. |
/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 |
— |
/v1/schedule/{class_id}/services
Datastream
Pricing options that can pay for one class
What a client could buy in order to be booked into this specific class. Requires `raw:read`. **This is a live Mindbody read, not a mirror read** — the only GET in this API that is. `ds_mbo.service` holds the catalog, but *which* services a particular class accepts is a relationship Mindbody keeps and the mirror does not carry, on either layer. Its reason for existing is the `payment_required` refusal from `POST /v1/schedule/{class_id}/bookings`. Without this, a consumer facing that refusal can only offer the studio's whole price list — and some classes (free community classes, intro offers, comps) have an option that costs nothing, which Mindbody still requires be claimed before it will book. `is_free` is that answer, per option. The class is resolved inside the key's site scope first, so a class the key cannot read is a `404` and never reaches Mindbody. By default only options the studio sells online are returned — an option a studio will not sell online is not one a kiosk may sell. `sell_online=false` opts a staff-side caller out of that filter. Not cached server-side, and Mindbody meters calls: fetch this when a booking has actually been refused, not on every schedule render. The response carries `Cache-Control: private, max-age=300`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
class_id (path) |
Yes | string |
— |
free_only (query) |
No | string |
true, false |
sell_online (query) |
No | string |
true, false |
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. |
/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`. **One thing is checked after the fact: that the visit was paid for.** `RequirePayment: true` is not a guarantee — Mindbody will sometimes accept the booking and return a visit with no `service_id`, which puts someone on the roster for free. When that happens on a class that sells only paid options, the visit is removed again (scoped to that visit, so an existing booking survives) and the call returns `422 payment_required`. A class offering a $0 option, or nothing at all, is treated as free and the visit stands. `require_payment: false` opts out of the whole check. If the rollback itself fails, the response is a `503` naming the visit that needs removing by hand. 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/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. |
/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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/schedule/{class_id}/substitute
Datastream
Assign a substitute through Mindbody's substitution verb
Assigns a substitute teacher to one class instance via Mindbody's `substituteclassteacher`. Requires the `schedule:write` scope, and is served only where the deployment has writes enabled. **Not the same call as `PATCH /v1/schedule/{class_id}/teacher`**, and the difference matters. That route uses `updateclass` and simply sets `staff_id`. This one uses Mindbody's substitution verb, which also takes `override_conflicts` — and with the override off (the default here) Mindbody **refuses a substitute who already has something booked in that slot**. `updateclass` does not ask that question at all. For an automated substitute flow that check is the point: it is the only thing between "the first willing teacher said yes" and a double-booked teacher, and a double-booking surfaces as two rooms expecting the same person. Use the `teacher` route for a studio admin setting who is teaching. Use this one when a substitute is being placed and the conflict check should stand. All three e-mail flags default to `false`. A substitution that e-mails a studio's whole booked roster is not something to inherit by forgetting a field. The class is resolved inside the key's site scope first — a class the key cannot read is a `404`, and a cancelled class is a `409`. **Does not write to Datastream.** The mirror picks the change up on its next sync, so a schedule read straight afterwards still shows the old teacher.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/schedule/{class_id}/cancel
Datastream
Cancel one class occurrence
Cancels a single scheduled class via Mindbody's `cancelsingleclass`. Requires the `schedule:write` scope, and is served only where the deployment has writes enabled. **`send_client_email` has no default and must be stated.** `true` makes Mindbody e-mail every student booked into the class. For the flow this was built for, that e-mail *is* the student notification — the caller holds no student phone numbers and no SMS consent, so nothing else tells them the class is off. Both values are consequential in opposite directions (a silent cancellation, or an unexpected blast to a roster), which is exactly when a default is the wrong shape. Mindbody's verb is a cancellation, not a delete: the class stays on the schedule flagged cancelled, which is what a student looking for it needs to see. `hide_cancel` hides it anyway and defaults to `false`, because a hidden cancellation reads to a student like a class that was never there. An **already-cancelled** class is a `409` rather than a no-op. Refusing a redundant cancel costs one confusing message; allowing it risks a second e-mail to a roster that was already told. Send an `Idempotency-Key`. A retried cancel is a second e-mail to everyone who was booked. **Does not write to Datastream** — the mirror reflects the cancellation on its 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/schedule/{class_id}/restore
Datastream
Restore one cancelled class occurrence
Reopens a single cancelled class via Mindbody's `updateclass` (`IsCanceled: false`). Requires `schedule:write`. Mindbody's `cancelsingleclass` has no inverse verb. Whether this actually restores a class cancelled through that verb — and whether booked students come back — is unverified (docs/VERIFY.md §42). A class that is not cancelled is a `409`.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/schedule/{class_id}/history
Datastream
Change history for one class
The writes **this API** made against one class instance and its recurring definition. Requires `raw:read`. Mindbody has no class audit trail. These rows are `ds_api.schedule_write` (sql/038): `created`, `updated`, `substituted`, `cancelled`, `restored`. A change typed in the Mindbody back office will not appear. Empty when the table has not been applied, or when nothing has been written through this API. Fail-open — never a 503 for a missing table. Series-level edits are matched by `class_schedule_id` so a definition change shows on every occurrence.
| 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. |
limit (query) |
No | integer |
— |
offset (query) |
No | integer |
— |
/v1/schedule/{class_id}/live
Datastream
One class as Mindbody has it right now
The current teacher and cancellation state of one class, read **live from Mindbody**. Requires `raw:read`. Use `GET /v1/schedule/{class_id}` for anything that renders a schedule — it reads the mirror and costs nothing. Use this one when the question is specifically *"has this changed in Mindbody since we last looked?"*. Two reasons the mirror cannot answer that: - **Freshness.** The mirror is refreshed by sync windows and webhooks. A substitute assigned by hand in Mindbody minutes ago needs to be visible within one cron tick, not one sync window. - **`is_cancelled` is not on the mirror read at all.** `/v1/schedule` strips it, because that route excludes cancelled classes by default and the flag would always read `false` there. A consumer asking "was this cancelled?" cannot get an answer from it. **Tenancy is `site_id`, not a mirror lookup.** Every other per-class route proves the class is in scope by reading the mirror first. That would defeat this endpoint — the class asked about may be one the mirror has not absorbed yet, which is a case it must answer rather than `404`. So the caller names `site_id`, it is checked against the key's own scope, and Mindbody scopes the class id to that site through its `SiteId` header. Mindbody class ids are per-site sequential and collide across sites, so that header is what makes the lookup unambiguous. A class Mindbody does not know is a `404`, never an empty success — so a consumer cannot mistake "Mindbody has no such class" for "Mindbody says nobody is teaching it". Costs one metered Mindbody call per request and is `no-store`. A stale "not cancelled" is the answer that sends someone to a class that is off.
| 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. |
/v1/schedule-definitions
Datastream
List recurring class schedule definitions
The recurring **definition** a class is generated from — as opposed to `/v1/schedule`, which lists the generated instances. Reads `ds_mbo.class_schedule`. Requires `raw:read`. Its purpose is prefilling an edit form: before this table began syncing (2026-08-18) nothing exposed what a schedule was actually set to, so editing a class could only have been a blind overwrite. **Four fields Mindbody does not return on this resource**, verified as 0 of 612 rows at both the flat and nested paths: `staff_pay_rate`, `booking_status`, the room (`resource_id`) and any capacity. Pay rate and booking status are exactly the two fields Mindbody *demands* when publishing, so an editor can show every other current value and must prompt for those two with no default — defaulting `booking_status` silently changes whether students must pay to book. Room and capacity are recoverable per-instance from `/v1/schedule` (`room_id`, `max_capacity`); the other two are recoverable nowhere. Coverage is partial and still filling — 612 rows across 4 sites, up from 362 across 1 site six hours earlier. A site whose schedules have not synced returns an empty list, which is indistinguishable here from a site that has none.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
class_description_id (query) |
No | string |
Schedules for one class type. The id `POST /v1/schedule-definitions` takes. |
staff_id (query) |
No | string |
Schedules whose **permanent** teacher is this staff member. |
location_id (query) |
No | string |
— |
schedule_active (query) |
No | string |
true, false |
modified_since (query) |
No | string (date-time) |
— |
limit (query) |
No | integer |
— |
offset (query) |
No | integer |
— |
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. |
/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. Mindbody's `LocationId` is a separate required body field — one site can hold several Mindbody locations, so it cannot be derived from `site_id`. Returns the new schedule's id and the class instances Mindbody generated from it. Those instances are not readable from `GET /v1/schedule` immediately — they appear once the mirror picks them up.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/schedule-definitions/{class_schedule_id}
Datastream
One recurring class schedule definition
The definition behind a series, by its `class_schedule_id` — the id `/v1/schedule` returns on every instance it generated, and the one Mindbody's own back office puts in its edit-class URL. Requires `raw:read`. Dates come back as `YYYY-MM-DD` and times as `HH:MM:SS`. Mindbody stores each the other way round from how it reads: a date carries a meaningless midnight, and a **time carries a meaningless date** — `1899-12-30`, the OLE Automation epoch. Both are normalised here, so no consumer has to know that. See the list operation for the four fields this resource cannot tell you, which is the constraint that shapes any edit UI built on it.
| 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. |
/v1/schedule-definitions/{class_schedule_id}
Datastream
Edit a recurring class schedule
Edits the recurring definition through Mindbody's `updateclassschedule` — the class type, location, permanent teacher, days, dates, times, room and capacity. Requires `schedule:write` and a deployment with writes enabled. `site_id` is required for the same reason it is on the publish route: no mirror row proves tenancy before the call. **PATCH semantics, and they are load-bearing.** Send only the fields you are changing; an omitted field is left alone. A full-replacement PUT is not offered because it cannot be done safely — Mindbody does not return `staff_pay_rate` or `booking_status` on a schedule, so no caller can construct a complete representation, and a PUT would blank whatever it omitted. **Unverified and potentially destructive:** whether *Mindbody* treats an omitted field as "leave alone" or as "clear". If it clears, an edit wipes the pay rate and booking status of a live class — and those are precisely the two fields this API cannot read back to detect it. Until one live call settles this, treat any edit as potentially destructive to them. **Changing time, date or days may require clearing the room first.** Mindbody's back office states the room must be removed before those change, making a reschedule a clear → change → reassign sequence with a partial-failure window. Send `room_id: 0` to clear. Whether the constraint applies to a class with no bookings is unverified.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
class_schedule_id (path) |
Yes | string |
— |
site_id (query) |
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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same key. |
/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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/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. |
/v1/appointments
Datastream
1:1 appointments — the private-session counterpart to /v1/schedule
ds_enriched.appointments — massage, private yoga, intake assessments. A studio that sells these has staff time and revenue here that appears nowhere in the class endpoints. `status` is Mindbody verbatim and an OPEN set (Completed, Booked, Cancelled, Arrived, …), passed through rather than mapped to an enum. Cancelled rows are INCLUDED by default, because a report on lost revenue needs them; pass `exclude_cancelled=true` for the day view a front desk wants. `client_id` is a string, not an integer. Mindbody lets a site choose its own client id format and at least one live site uses the phone number ("(512) 689-5226"), so do not parse it as numeric. `client_name` and `client_email` are denormalized onto the row and are **`null` when the client is not mirrored yet** — fall back to `client_id` rather than rendering an empty row. **Show `program_name`, not `session_type_id`.** Mindbody puts no service NAME on an appointment, only a numeric session type nothing can resolve. `program_name` is the studio's own category — "Massage", "Skincare", "Acupuncture", "Yoga Privates" — resolved through the service catalog, and is the label a front desk reads. Two source fields are deliberately absent: `notes` (free-text staff scratch carrying client phone numbers and medical detail) and `onlineDescription` (the service's marketing HTML, identical across every appointment of a session type). Both remain in ds_mbo.appointment. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (query) |
No | string |
Mindbody client id — a string |
client (query) |
No | string |
Client display name |
program (query) |
No | string |
Studio category |
staff_id (query) |
No | string |
Provider |
provider (query) |
No | string |
Provider display name |
location_id (query) |
No | string |
— |
location (query) |
No | string |
Location display name |
status (query) |
No | string |
Exact match on the Mindbody status |
exclude_cancelled (query) |
No | boolean |
Drop Cancelled rows without enumerating every other status |
session_type_id (query) |
No | string |
— |
staff_requested (query) |
No | boolean |
Client asked for this provider by name |
first_appointment (query) |
No | boolean |
— |
start_after (query) |
No | string (date-time) |
— |
start_before (query) |
No | string (date-time) |
— |
modified_since (query) |
No | string (date-time) |
— |
/v1/appointments/{appointment_id}
Datastream
One appointment
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
appointment_id (path) |
Yes | string |
— |
Datastream · events 7
/v1/enrollment-definitions
Datastream
Publish a new enrollment (workshop, series, teacher training)
Creates an enrollment via Mindbody's `addenrollmentschedule` — the class type, location, days/times, its teacher, and the seats and pricing options a place is sold with. Mindbody generates the individual occurrences; the mirror picks them up on its next sync. Requires `schedule:write`, and is served only where the deployment has writes enabled. **An enrollment is not a class schedule, and this is not how you enrol someone.** Three neighbouring endpoints are easy to confuse: | Endpoint | Creates | |---|---| | `POST /v1/schedule-definitions` | a recurring **drop-in class** | | `POST /v1/enrollment-definitions` (this one) | a bounded **enrollment** | | `POST /v1/events/{event_id}/enrollments` | nothing — it fills a seat in one | An enrollment runs for a fixed span and is bought as a whole rather than dropped into, which is why it carries `end_date`, a waitlist and an explicit pricing-option list where a class does not. `site_id` is required here (not just a narrowing filter): the enrollment does not exist yet, so there is no mirror row to read tenancy off, and the caller's own site scope is checked against it directly. Mindbody's `location_id` is a separate required body field — one site can hold several Mindbody locations, so it cannot be derived from `site_id`. Returns the new schedule's id and the occurrences Mindbody generated. Those are not readable from `GET /v1/events` immediately — they appear once the mirror picks them up.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/enrollment-definitions/{class_schedule_id}
Datastream
Edit an existing enrollment
Updates an enrollment via Mindbody's `updateenrollmentschedule`. Requires `schedule:write`, and is served only where the deployment has writes enabled. **Only the fields you send are written.** That is forced rather than chosen: four fields on an enrollment cannot be read back anywhere — `staff_pay_rate`, `booking_status`, the room and any capacity (see §30 in `docs/VERIFY.md`, which measured 0 of 612 class-schedule rows carrying them; `/v1/events` has the same gap). A caller therefore cannot reconstruct the current record, so a full-replace contract would force everyone to invent values for the fields they cannot read — and a guessed `booking_status` silently changes whether students must pay to book. The response echoes `updated_fields` so you can see what actually went. **Two fields are deliberately not accepted here**, both settled against Mindbody's published request bodies: - `pricing_option_ids` — **pricing cannot be changed on an update at all.** `PricingOptionsProductIds` is in `addenrollmentschedule`'s body and simply not in `updateenrollmentschedule`'s. Set it at creation, or change it in the Mindbody back office under "Assign pricing option". - `class_description_id` — Mindbody documents `ClassDescriptionId` on this endpoint as *"Used only internally, overridden if sent"*, so an enrollment's class type cannot be changed here. Rejecting it beats returning a 200 for a change that never happened. Both are a 400 naming the field, not a silent drop. **Mindbody leaves an omitted field alone** — confirmed 2026-09-03 by a single-field patch that left the room, capacity, pay rate, booking status, times, dates, teacher and location all untouched (`docs/VERIFY.md` §40). Sending only what you mean to change is the intended way to use this endpoint. Mindbody regenerates occurrences when the dates or days change, so the response can carry a fresh `class_instance_ids` list. Changes are not visible on `GET /v1/events` until the mirror syncs.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
class_schedule_id (path) |
Yes | string |
The enrollment's schedule id, as returned by `POST /v1/enrollment-definitions`. |
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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same key. |
site_id (query) |
Yes | string |
The 32-character Datastream site id the enrollment belongs to. Must be one of the key's own sites — anything else is a 403. Required because a Mindbody `class_schedule_id` is not a query-builder resource, so there is no mirror row to prove tenancy against, and Mindbody ids are per-site sequential and collide across sites. |
/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). `image_url` is served, but sourced differently from every other field here: `flow_cms_posts` / `flow_cms_post_attachments` are also the Portal's schema, so this API reads them over the Portal's events-to-thirdparty HTTP API (keyed by `class_description_id`, not `event_id`) rather than joining them in SQL. It is `null` whenever the Portal has no matching post/attachment, and whenever `PORTAL_EVENTS_API_URL` / `PORTAL_EVENTS_API_KEY` are unset — a Portal outage costs a missing image, never a failed request.
| 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 that start on or after this date. Does not keep a long-running enrollment whose first day is already in the past (use the event itself if you need the full span). |
end_date (query) |
No | string (date) |
Events that start on or before this 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 |
— |
/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. |
/v1/events/{event_id}/services
Datastream
Ticket tiers for an event
What a place at this event costs. Requires `raw:read`. **Live Mindbody, not the mirror** — same reason as `/v1/schedule/{class_id}/services`: the catalog is mirrored but which options a given schedule accepts is not. In Mindbody an enrolment IS a class schedule, so this asks `/sale/services` by `classScheduleId` (the event id), not by `classId` (one occurrence). The distinction matters: an occurrence key returns a plausible list for the wrong thing. Only options the studio sells online are returned by default; a staff comp tier flagged `SellOnline: false` is not something a kiosk may sell. `Cache-Control: private, max-age=300`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
event_id (path) |
Yes | string |
— |
free_only (query) |
No | string |
true, false |
sell_online (query) |
No | string |
true, false |
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. |
/v1/events/{event_id}/enrollments
Datastream
Enrol a client in an event
Puts a client in an event. Requires `booking:write`, and writes only where the deployment has writes enabled. **This does not write to Datastream** — the enrolment is proxied to Mindbody and the mirror picks it up on the next sync. **One enrolment buys the series.** `enroll_date_forward` defaults to today in the studio's own timezone, which takes every remaining date of a run already under way and every date of one that has not started. Pass it explicitly to start somewhere else. Past dates are never enrolled into. **This endpoint does not take payment, but it does now check for it.** Mindbody's `addclienttoenrollment` has no `RequirePayment` (unlike `addclienttoclass`) and will enrol whoever it is given, so the check is made here: a client who holds no pricing option this event accepts is refused with `payment_required` (422) and nothing is sent to Mindbody. Sell the ticket first — `GET /v1/events/{event_id}/services` lists what this event takes, and `GET /v1/clients/{client_id}/has-credits` shows what the client already holds without attempting the write. The response reports `final_service_id` — the option that authorised the enrolment. It is **not** sent to Mindbody and is not a receipt: `addclienttoenrollment` has no `ClientServiceId` field, so Mindbody chooses which pack to draw down itself. Mindbody does not dedupe: the same client enrolled twice takes two spots. Send an `Idempotency-Key` and a repeat replays the first response. The response carries `dates` — every occurrence the enrolment landed on, which is how a caller finds the one happening today to check someone in against (`POST /v1/schedule/{class_id}/roster/{visit_id}/check-in`).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
event_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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/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 · sync 5
/v1/sync/trigger
Datastream
Trigger a scoped MBO sync job (one site + one data family)
Dispatches a `sync-ManualTrigger` job on the itflow_datastream sync droplet — one site, one data family, an optional date range — via a direct HTTP call (over the private VPC) to that droplet's internal sync-trigger listener. Requires the `sync:write` scope. **Fire-and-forget: this does not wait for the sync to finish.** A real sync can run for minutes; the response reports only that the job was *dispatched*, via `202 Accepted`. Completion (success or failure) is logged to this service's own stdout, not returned in the response. `site_id` is this service's own 32-character hex Datastream site id (`ds_config.sites._id` — the same value `GET /v1/sites` returns as `site_id`), **not** an MBO numeric site id. itflow_datastream's own sync-control lookup keys off that same hex id. This route checks only the `sync:write` scope, not per-site tenancy — grant the scope accordingly.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same key. |
/v1/sync/services
Datastream
Re-pull one site's pricing options from Mindbody into the mirror, now
Asks the itflow_datastream sync droplet to re-read Mindbody's `/sale/services` for one site and write it to `ds_mbo.service`, then answers once the rows are written. Requires the `sync:write` scope. What it is for: `ds_mbo.service` (and so `/v1/packages`) is otherwise only as fresh as the last ingest run (08:00, 18:00, 23:00 UTC), so a pricing option added in Mindbody cannot be picked until the next run. Call this, then re-read `/v1/packages`. Rally's "Sync pricing list" does exactly that. **Waits, unlike `/v1/sync/trigger`.** One Mindbody call per 1,000 options, so the answer comes back in seconds with the count written. **Not a sync run.** No execution is created and no watermark moves, so it cannot stand in for a scheduled SalesFamily run the way a `/v1/sync/trigger` services pull would. The sync droplet writes the rows; this service only asks. `site_id` is required and must be inside the key's scope (unlike `/v1/sync/trigger`), because it picks the studio whose Mindbody is called. Presses for a site that is already refreshing share one refresh. `Cache-Control: no-store`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/sync/enrollment
Datastream
Re-pull one enrollment and all its dates from Mindbody into the mirror, now
Asks the itflow_datastream sync droplet to re-read ONE enrollment and every one of its dates from Mindbody, write them to `ds_mbo`, project them into `ds_enriched`, and mark the dates Mindbody no longer has for it. It answers once that is done. Requires the `sync:write` scope. What it is for: `POST /v1/enrollment-definitions` writes no mirror row, so a freshly published event reaches `/v1/events` only at the next ingest run, and its dates reach `/v1/schedule` only once they are inside the 90-day class window. Call this after creating or editing an enrollment. Funnel does, after it publishes or edits an event. **Dates are reconciled.** A date stored for the schedule that Mindbody no longer returns is marked removed, which `/v1/schedule` filters out (unless `include_stale=true`), and one it returns again is restored. An edited event therefore does not keep its old date next to the new one. **Not a sync run.** No execution is created and no watermark moves, so it cannot stand in for a scheduled ClassFamily run the way a `/v1/sync/trigger` would. The sync droplet writes the rows; this service only asks. `found: false` means Mindbody has no enrollment with that id, and nothing was written. `enriched: false` means the sync droplet has no `ds_enriched` connection: the mirror was written, but nothing was projected or marked. `site_id` is required and must be inside the key's scope. `Cache-Control: no-store`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
class_schedule_id (query) |
Yes | integer |
The enrollment's Mindbody class schedule id (`event_id` on `/v1/events`). |
/v1/sync/history
Datastream
Recent sync execution history for one site + data family
Reads recent execution history for one site + data family from the itflow_datastream sync droplet's internal listener (`/internal/sync-history`, itflow_datastream PR #41), via the same private-VPC HTTP call `POST /v1/sync/trigger` uses. Requires the `sync:write` scope — this is read access to an internal admin/ops feature (recent sync executions), not a general-purpose Datastream read, so it is gated by the same scope as triggering a sync rather than a `*:read` scope. `site_id` is this service's own 32-character hex Datastream site id (`ds_config.sites._id` — the same value `GET /v1/sites` returns as `site_id`), **not** an MBO numeric site id, and this route checks only the `sync:write` scope, not per-site tenancy — same caveat as `POST /v1/sync/trigger`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
— |
family (query) |
Yes | string |
ClientFamily, ClassFamily, TransactionFamily, SalesFamily |
limit (query) |
No | integer |
Most-recent-first. Defaults to 10, capped at 50. |
/v1/sync/next-range
Datastream
The date range the next scoped MBO sync would use, for one site + one data family
Read-only companion to `POST /v1/sync/trigger`: computes what `date_start` / `date_end` a sync launched right now, with no explicit dates, would resolve to — without launching anything. Requires the `sync:write` scope (same reasoning as the trigger route: read-only, but only useful to a caller that can already trigger a sync). Calls the sync droplet's internal `GET /internal/sync-status` listener for the SyncControl's raw stored `between.start` / `between.end`, then applies the same date arithmetic as itflow_datastream's `SyncControlService.launchExecution`: `date_end` is yesterday in US/Central, and `date_start` is `between.end + 1 day` (or `between.start` on a SyncControl that has never run). Backs the FETCH admin page's date-input prefill in api-fvmgt.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
This service's own 32-character hex Datastream site id (`ds_config.sites._id`), the same value `GET /v1/sites` returns as `site_id` and the same id space `POST /v1/sync/trigger` uses. |
family (query) |
Yes | string |
ClientFamily, ClassFamily, TransactionFamily, SalesFamily |
Datastream · sales 41
/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`. A service Mindbody has locked to contract sales (`sale_in_contract_only`) is refused with 409 naming the contract to sell instead. 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same key. |
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/free-packages
Datastream
The $0 pricing options a studio may hand out
The read side of `POST /v1/get-free-package`: where that answers "may I comp this one", this answers "which ones are on offer at all". Requires `raw:read`. **Not a filter on `/v1/packages`.** `$0` is a fact about the catalog; "this studio hands this one out at the desk" is a policy decision about it, and so is "ClassPass posts its own visits, so never comp it by hand". Folding that into the general catalog read would make every other consumer opt out of one consumer's rules. **The policy lives server-side on purpose.** The console it serves sits behind a proxy with no authentication in front of it, so a list held in a page's JavaScript decides what that page draws and nothing more. Five exclusions, applied in the order they are cheapest: discontinued, priced above zero, a program outside the allowlist, a denied name (ClassPass, Dynamic Pricing, Late Cancelled, No Show, Consultation), and anything a membership carries. That last one takes two signals — MBO's own `sale_in_contract_only` lock catches three options on the Flow site, while membership of a contract's `contract_items` catches six, and the extra three are $0 and unlocked, so the cart would happily sell one on its own. The program allowlist is by **id**, not name, so a studio renaming a program cannot silently change what the API offers. Mindbody program ids are per-site, so a second studio may need its own ids added before its options appear — the symptom is options quietly absent, not an error. Unpaged: this is a policy list a consumer renders whole, not a catalog page. `total_count` equals the number of rows returned.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
q (query) |
No | string |
Free-text match on the option name, two characters minimum. |
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. |
/v1/get-free-package
Datastream
Comp a $0 pricing option to a client
Adds a pricing option priced at **$0** to a client's Mindbody account as a comp. Requires the `purchase:write` scope, and is served only where the deployment has writes enabled. **No money moves and Square is never involved.** The cart is tendered against Mindbody's own built-in `Comp` type — the one the front desk uses to hand someone a free pass. There is no `payment_method_id`, no stored card, no token and no Square payment record. That is why this is its own endpoint rather than a mode inside `POST /v1/purchases`, which requires a payment source. **The price is verified server-side.** The caller names a pricing option; it does not get to assert that the option is free. This endpoint re-reads the price from the mirror inside the key's own site scope and refuses anything that is not exactly zero with `400 Selected package is not free`. An option whose price the mirror does not know is refused the same way — unknown is not free. **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. The grant is separately recorded in `ds_api.free_package_grant` as an audit ledger — who comped what, to whom, on which key. The option is resolved inside the key's site scope first, so one the key cannot read is a `404`, never a `403`. An option Mindbody has locked to contract sales (`sale_in_contract_only`) is refused with a `409` naming the contract to sell instead. **Once every 6 months, per client, per pricing option.** A client who already received this option inside that window is refused with a `409` and the member-facing message *"It looks like you've already claimed this offer. Please contact support for more details."* The specifics — when it was granted, which sale, which package — are deliberately kept out of the message and written to the server log against the request id instead, so support can answer a follow-up without the refusal itself starting an argument at the front desk. The limit is per `(site, client, package)` — a *different* free option is unaffected, and it is not a blanket cooldown on free passes. Checked on a dry run too, so `test: true` answers what the real call would do. **Event tickets are exempt.** A pricing option on the `Event Single-Day` or `Event Multi-Day` program skips the check entirely: events recur, a member may legitimately attend several in six months, and one `$0 Ticket` option is what admits them to every one. The response reports `program` and `repeat_limit_applied` so a consumer can see which rule was applied without knowing this list. An exempt option never returns the `503` below either — it does not read the ledger at all. The exemption matches the program **name**, which is studio-configured rather than a Mindbody constant, and the match is exact rather than a prefix on "Event". A program outside that pair keeps the limit, which is the safe direction: an event ticket wrongly limited is a visible `409` someone reports, while a standing offer wrongly exempted is an unlimited giveaway nobody notices. The check reads `ds_api.free_package_grant`, so it only knows about grants made through **this endpoint**. A comp handed out directly in the Mindbody UI, or before this endpoint existed, is invisible to it and does not start the clock. **`test` defaults FALSE here**, unlike `POST /v1/purchases`. That endpoint moves money, so a real sale has to be asked for out loud; this one moves none, and defaulting to a dry run would mean every caller has to opt in to the endpoint doing its job. Send `test: true` to have Mindbody validate the whole cart without creating a sale — the response is a `200` with `sale_id: null`, and nothing is recorded. Mindbody does not dedupe sales. Send an `Idempotency-Key` and a repeat replays the first response instead of handing out a second free pass.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same key. |
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/gift-cards
Datastream
The gift card denominations a site sells
A **live Mindbody read**, like `/v1/payments/methods`. `ds_enriched` records gift cards that were *sold*, but the denominations a studio currently offers are Mindbody configuration and nothing mirrors them. `source` is `mbo` on every row. Gift cards are configured per site, so a key covering several sites must name one with `?site_id=` — the endpoint will not guess, because guessing sells the wrong studio's card. Two prices, and they answer different questions. `card_value` is what the recipient can spend; `sale_price` is what the purchaser is charged. They are equal on the Flow site today, but a "pay $80, get $100" promotion separates them, and showing the wrong one either undercharges or misprices the offer. Zero-value cards are omitted — Mindbody keeps them as templates and a storefront offering "$0 Gift" looks broken.
| 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. |
/v1/gift-cards/purchases
Datastream
Buy one gift card
Buys a gift card via Mindbody's `purchasegiftcard` and emails the recipient. Requires `purchase:write` and a deployment with writes enabled. Replaces the legacy `/v2/products/store/giftcard/purchase`. **The price comes from the catalog, not the request.** The legacy endpoint charged whatever `amount` the caller sent — a POST naming the $100 card with `amount: 1` charged a dollar and issued a hundred-dollar barcode. Here the server looks the card up and charges its `sale_price`. `amount` may be sent as an assertion; if it disagrees the request is a `400`, not a discount. **This does not write to Datastream.** The purchase is proxied to Mindbody, the system of record; the sale reaches `ds_enriched.sales` on the next sync. **`test` defaults TRUE.** A dry run has Mindbody validate the whole purchase, creates nothing, issues no barcode and sends no email. A real purchase must say `test: false` out loud. **No card number is accepted.** `stored_card` charges the card Mindbody already holds for the purchaser, identified by its last four. `custom` records the sale against a Mindbody custom payment method without charging anything — for when Square (or anything else) took the money first. Sending a PAN is a `400`. Mindbody's own gift card receipt is suppressed; Flow's branded email carries the chosen design, the message and the barcode. That send is best-effort: by the time it runs the money is taken and the barcode exists, so a failed email is reported as `email_sent: false` rather than failing a completed purchase. Mindbody does not dedupe. Send an `Idempotency-Key` and a repeat replays the first response instead of issuing a second card.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/payments/square/locations
Datastream
Square merchant Locations on this account
Square's own Locations (`GET /v2/locations`), not Mindbody studios and not the mapping table. Use the ids here to rempoint `ds_api.square_location_map` once real studio Locations exist. Requires only a valid key — same as `/v1/payments/square/config`.
| 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. |
/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) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/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 exactly one of `source_id` (a Web Payments SDK token from the browser) or `card_id` (a card already on file for this client; Square customers are keyed `<site_id>:<client_id>`). 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) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/payments/square/failed-attempt
Datastream
Report a card-entry failure that never reached /charge
The Web Payments SDK refuses a bad card number, an incomplete form, or a cancelled Apple Pay sheet in the browser — none of those call `/v1/payments/square/charge`, so the purchase-failure alert hook never sees them. The card form POSTs here instead. Does not move money. Requires `payment:write`.
/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.
/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 |
— |
/v1/payments/square/plans/from-contract
Datastream
Copy a Mindbody contract onto a Square subscription plan
Reads the Mindbody contract's name, recurring total, and autopay cadence, then creates a Square `SUBSCRIPTION_PLAN` with one variation at that price — or returns the existing variation when one already matches name and amount. Catalog objects cannot be deleted through this API, so a reuse is preferred to a second copy. This is the Plan half of Subscribe. The contract id is still stored on the subscription so each paid invoice can sell the standing pack; Square never enrolls a Mindbody membership.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
Idempotency-Key (header) |
Yes | string |
— |
/v1/payments/square/cards
Datastream
Square cards on file for a Mindbody client
Enabled cards stored on the Square Customer keyed `<site_id>:<client_id>`. A client who has never been billed at Square has no customer — this returns an empty list, not a 404. Disabled cards are omitted. Scope is `payment:write`: the read hits Square with the merchant credential.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (query) |
Yes | string |
Mindbody client id. |
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/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) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/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**. **When `mbo_contract_id` is set, this request sells the standing ServicePricingOption (method 19) after Square charges** — `mbo_entitlement` is `sold` or `failed`. Waiting on `invoice.payment_made` for the first period left paid members with no pack: that webhook often arrives before the local subscription row exists. Later cycles still sell on the webhook. That is a pack, not a membership. Without a contract id, `mbo_entitlement` is `none`. 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) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/payments/square/subscriptions-with-credit
Datastream
Square subscription → Mindbody account credit → Mindbody contract bought with that credit
The same Square flow as `POST /v1/payments/square/subscriptions` — find-or-create the Customer, store the card, charge the first period, create the subscription on the next cadence, refund if that fails — and the same request fields, plus `test`. **The Mindbody half differs.** Instead of selling a pack, this calls `/sale/purchaseaccountcredit` (Custom method 19, Account Payments item `13380`) for **exactly the amount Square charged**, so the client's Account Balance rises by the money that moved. It never calls `checkoutshoppingcart`. `promo_code` is priced from `ds_mbo.promo_code` (Percent / FlatRate), not a cart dry run: it must exist and be active, but its dates and applicable items are not checked. The discounted first period is what Square charges and what is credited; renewals stay on the plan amount. `test` defaults **true**: no Square write (the plan list is read for the price) and Mindbody `purchaseaccountcredit` with `Test: true`. It returns `200` with what would be charged and credited. A non-null `mbo_sale_id` there means Mindbody did not honour `Test`. `test: false` needs `Idempotency-Key` and exactly one of `source_id` / `card_id`, and returns `201`. `mbo_credit` is `added`, `failed` or `none` (nothing charged today because `start_date` is in the future). On `failed` the subscription is kept and ops is alerted to add the credit by hand — there is no automatic refund, and retrying through `mbo-credit` would charge Square again. `account_balance_after` is read live from Mindbody, falling back to `account_balance_before + credit_amount`. **Step 3** (after the credit lands): `/sale/purchasecontract` for the required `mbo_contract_id` with `UseAccountCredit: true` and the same `promo_code` as `PromotionCode`. Requires `contract:write` as well as `payment:write`. `mbo_contract` is `purchased`, `failed` (subscription and credit kept; retry `POST /v1/contracts/{id}/purchases` with `use_account_credit` — nothing charges twice), `skipped` (the credit failed) or `none` (nothing charged today). **Square's charge, the credit and the contract's first payment must match to the cent.** The contract side is its `first_charge_total` minus the same catalog promo. A mismatch, or a contract not sold at the location, is `409` before any Square write — also in test mode. The contract sale is read back afterwards; if Mindbody debited a different amount, `contract_charge_mismatch` is true and ops is alerted. `test: true` never calls `purchasecontract` — Mindbody has no dry run for it — and reports `mbo_contract: would_purchase`. `mbo_contract_id` is not stored on the subscription row, so renewals add no credit and sell no pack yet, while Mindbody's own autopay on the contract debits the account each cycle.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
Idempotency-Key (header) |
No | string |
Required when `test` is false. |
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/payments/square/subscriptions/{subscription_id}/test-charge
Datastream
Removed — use /accelerate
Removed. This charged the card and sold the pack without a Square subscription invoice. Use `POST …/accelerate` instead.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
subscription_id (path) |
Yes | string |
— |
/v1/payments/square/subscriptions/{subscription_id}/accelerate
Datastream
Test accelerator — real Square invoice via daily swap or recreate
Square will not generate a subscription invoice before `charged_through_date`. That field is read-only. SwapPlan takes effect at period end. There is no production "bill now". `mode=swap_daily` parks this subscription on a same-price DAILY variation. Same Square id / member URL. The next invoice is still `charged_through_date`; after that, invoices are daily. `mode=recreate_daily` cancels this subscription at the end of the paid period and CreateSubscriptions a DAILY replacement with `start_date` today so Square invoices immediately. New Square id, new member URL. Neither mode charges a card or sells a pack. The existing `invoice.payment_made` webhook is the entitlement path. Fail-closed: `payment:write` **and** `ENABLE_SUBSCRIPTION_TEST_ACCELERATE` (default off). 403 when the switch is off. Not a public member-page control — do not proxy this from `fvmgt.com/{id}`. Requires `Idempotency-Key`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
subscription_id (path) |
Yes | string |
— |
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. |
/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. |
/v1/payments/square/subscriptions/{subscription_id}
Datastream
One subscription, as the member sees it
The read behind the member landing page: what they pay, when it renews, which card, which studio, and everything already scheduled — in one call, so the page is not stitching four responses together in a browser. **`status` alone is not the state.** Square keeps a cancelled subscription `ACTIVE` until the period already paid for ends, and it omits pending changes unless the read asks for them. Read `is_winding_down`, `is_paused`, `ends_on`, `resumes_on` and `actions`; this endpoint always asks Square for actions so they are never silently empty. `next_billing_date` is Square's `charged_through_date`, not a date derived from the cadence — Square invoices on the date the member is paid through, and a locally computed date drifts the moment a pause or an anchor change lands. It is `null` when nothing further will be billed. `plan_name` is the **Mindbody contract's** name, not the Square plan's. The plan was created by copying the contract, so they usually match, but the contract is what the member bought and what studio staff will say back to them. `today` is the studio's date in the studio's zone, so a UI can bound a date picker without trusting the visitor's clock. Tenancy is this service's own record of the subscription, exactly as on `/cancel`: a subscription created straight in the Square dashboard is a 404 here, whatever the key's scope.
| 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. |
/v1/payments/square/subscriptions/{subscription_id}/events
Datastream
What has already happened to a subscription, from Square
Square's own event log for this subscription, oldest first. Distinct from `actions` on the read above, which is what is *scheduled* and has not happened. This is the only place a deactivation reason is visible — a card that stopped working shows up here as a `DEACTIVATE_SUBSCRIPTION` with Square's detail, and nowhere else in this API.
| 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. |
/v1/payments/square/subscriptions/{subscription_id}/payments
Datastream
Paid invoices on a subscription, oldest first
Recurring charges Square has already collected for this subscription. Built from `invoice_ids` on the live Square subscription, not from `/events` — Square's lifecycle log never includes a weekly renewal.
| 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. |
/v1/payments/square/subscriptions/{subscription_id}/pause
Datastream
Skip payments, move a renewal date, or stop billing from a date
One endpoint behind three member requests, because Square's pause is the only mechanism that serves any of them: - **"Skip my next payment"** — `cycles: 1`. - **"Bill me again on the 18th"** — `resume_date`. This is also how a **non-monthly** plan moves its renewal date; `/billing-anchor` only works for monthly cadences. - **"Stop taking payments after the 1st"** — `pause_date` with no resume. Square has **no scheduled cancel** (`UpdateSubscription` refuses to set a future `canceled_date`), so an open-ended pause from a date is the only way to express this. **It is a pause, not a cancellation** — the subscription survives and can be resumed. The read reports it as `payments_stop_after`, never as `ends_on`. `cycles` and `resume_date` are mutually exclusive; Square rejects the pair. `pause_date` combines with either. Without `pause_date` the pause takes effect at the end of the period already paid for, never mid-period. Square has no mid-period pause, and prorating one here would be this service inventing a refund policy. Undo by deleting the returned `PAUSE` action; that also removes the paired `RESUME`.
| 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. |
/v1/payments/square/subscriptions/{subscription_id}/resume
Datastream
Start billing again after a pause
With no `resume_date` this resumes **immediately**: the request sends Square's `IMMEDIATE` change timing, because Square's default (`END_OF_BILLING_CYCLE`) reads to a member as the button having done nothing.
| 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. |
/v1/payments/square/subscriptions/{subscription_id}/billing-anchor
Datastream
Move the renewal date of a monthly membership
**Monthly cadences only — Square's limit, not a choice made here.** Sending this for a weekly plan is refused by Square with a message naming `monthly_billing_anchor_date`, a field the request never contained, which reads like a caller mistake and is not one: the endpoint exists only for cadences that have a day-of-month. This service therefore refuses a non-monthly cadence with `400` **before** calling Square, and names the remedy — `POST …/pause` with `resume_date` moves the renewal date for every other cadence. `effective_date` is the date the member wants to renew on from now on. This also derives `monthly_billing_anchor_date` from that date's day of the month, so "renew on the 3rd" keeps meaning the 3rd next month — which sending `effective_date` alone would not do. An anchor of 29, 30 or 31 is refused with `400` rather than silently becoming "the last day of a short month": the member's intent is unrecoverable there, and Square would pick a substitute day rather than ask. Square prorates the cycle the change lands in. It does not skip one and it does not double-bill.
| 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. |
/v1/payments/square/subscriptions/{subscription_id}/actions/{action_id}
Datastream
Undo a scheduled change that has not taken effect yet
The only way back from a scheduled cancel, a pause or a queued anchor move. Square has no "uncancel", and `UpdateSubscription` cannot null a `canceled_date`. An `action_id` Square has already applied, or never had, is Square's own 404 passed through. Nothing is inferred from a missing action.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
subscription_id (path) |
Yes | string |
— |
action_id (path) |
Yes | string |
From `actions[].action_id` on the read or any write above. |
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. |
/v1/payments/square/subscriptions/mbo-probe
Datastream
Dry-run a Mindbody cart type for the Square-subscription entitlement spike
Sends `checkoutshoppingcart` with `Test: true` and custom payment method 19 (`SQUARE_MBO_PAYMENT_METHOD_ID`) so we can see whether Mindbody accepts `Item.Type` of `Contract`, `GiftCard`, or `Product` — the unresolved half of a Square subscription (VERIFY.md §15). Always a dry run. There is no `test` field and no commit path. Never calls `purchasecontract` (that endpoint silently ignores `Test` and would enrol a real Autopay). Never writes a sale. The catalog id is loaded through the query builder (`contracts` or `products`); an id outside the key's site scope is a 404.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/payments/square/subscriptions/mbo-entitle
Datastream
Option 1 — re-sell a contract's standing ServicePricingOption
Loads the Mindbody contract, skips every `oneTimeItem` intro line, and sends the standing `ServicePricingOption` through `checkoutshoppingcart` as `Item.Type: Service` against custom payment method 19 (`SQUARE_MBO_PAYMENT_METHOD_ID`). `test` defaults true (dry run, `sale_id` null, no card). `test: false` charges Square first (requires `source_id` and `Idempotency-Key`), then commits the Mindbody sale labeled Square and records `ds_api.square_payment`. If Mindbody fails after the charge, Square is refunded. Never calls `purchasecontract`. Does **not** assign the contract's Membership. The `invoice.payment_made` webhook uses the same cart write without a second charge — Square already billed. A standing option flagged `saleInContractOnly` (Unlimited 11145) cannot go through the cart. A `test` dry-run with `promo_code` then prices from `ds_mbo.promo_code` (`promo_priced_from_catalog`) so Subscribe can still discount the first Square charge.
/v1/payments/square/subscriptions/mbo-credit
Datastream
Option 2 — fund house credit from Account Payments item 13380
Funds a client's Mindbody house account so a later `purchasecontract` can use `UseAccountCredit` without a card. `via=cart` sends Account Payments item `13380` ("Square Account") through `checkoutshoppingcart` as `Item.Type: Product` with `Metadata.Price` / `Amount` set to the requested dollars. Live 2026-08-21: MBO accepted the id but priced the catalog line at $0 and 422'd a $1.01 payment. `via=purchaseaccountcredit` (default) calls `/sale/purchaseaccountcredit` with Custom method 19 and `AccountPaymentId: 13380`. Amount is first-class on this write. `test` defaults true (MBO only, no card charge). `test: false` is a real transaction: charge Square first (`source_id` + `Idempotency-Key` required), then fund the credit, and write `ds_api.square_payment` so `mbo-credit-refund` can send the money back. If Mindbody fails after the charge, Square is refunded automatically. `via=cart` is dry-run only. Never calls `purchasecontract`. Does **not** assign a Membership.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/payments/square/subscriptions/mbo-contract
Datastream
Charge Square and enrol a Mindbody contract
One write: charge Square for any house-credit shortfall, fund that credit, then `purchasecontract` with `UseAccountCredit`. The membership still shows Payment Method Account — Mindbody cannot take method 19 on that endpoint. The Square charge is the money; the credit+debit pair nets GIFT/Debit to $0. `test: true` (default) is local only. `purchasecontract` has no dry run and ignores `Test`. `test: false` requires `payment:write` and `contract:write`, `confirm_amount`, an `Idempotency-Key`, and `source_id` when a charge is needed. If Mindbody credit fails after the card charge, Square is refunded. If enrol fails after credit, Square stays charged — they have credit; retry enrol, do not charge again.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/payments/square/subscriptions/mbo-credit-refund
Datastream
Option 2 — refund the Square charge for a cycle
Sends money back through Square for a charge this service recorded in `ds_api.square_payment`. Looks up by `mbo_sale_id` or `square_payment_id`. A Mindbody sale that was only labeled method 19 (no ledger row) 404s — Square never took that money. Does **not** reverse Mindbody house credit or terminate a contract. `mbo_reversed` is always false. `Idempotency-Key` is required.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/v1/payments/square/reconcile-refunds
Datastream
Refund Square when a staff member refunded the sale in Mindbody
The design doc's §4. **Reads the mirror, calls Mindbody not at all.** There is no refund *event*, but `clientSale.created` fires for the return sale itself — a POS return is a new sale with negative lines — and it reaches the webhook receiver within a second, so `ds_enriched.sale_payments` already holds the negative payment row. That row is the refund, and it carries the tender. This replaced a live `/sale/sales` poll that could not work: it pulled a three-day window with `limit 200, offset 0` and no pagination, on a site that books more than 200 sales in a day, and it separately inspected the *original* sale — which never carries the refund tender, only `Returned: true`. A refund labeled **Account** (method 16, or a tender named exactly "Account") is house credit and Square is left alone (`skipped_account`). Any other tender refunds the linked Square payment. The link back to Square is client + amount **within two cents** + the sale's item ids as a tiebreak. The tolerance is not fuzziness: Square is charged the contract's tax-inclusive total and Mindbody's own return line can total a cent less, so exact-cent matching never fired for a subscription charge. **One return settles one charge, once.** Each refund stamps the return sale's id onto the ledger row it settled (`mbo_return_sale_id`, unique-keyed), consumed returns are skipped, and the Square idempotency key derives from the return id so a re-pairing is rejected at Square itself. Requires sql/031; without it this endpoint refunds **nothing** (fail-closed) rather than matching blind — matching without the binding is what over-refunded $3.05 on 2026-08-21 (VERIFY.md §35). At most 5 refunds per pass; the rest wait for the next pass behind an alert. Does **not** reverse Mindbody house credit or terminate a contract. There is no Public API for that. `payment:write` required. An in-process sweep runs the same reads every 60s as a backstop against a missed webhook — but only when flow-notify is configured, so unattended money movement always has a reachable human.
/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. `invoice.scheduled_charge_failed` also opens the +1h / +3d / +7d / +10 dunning ladder in `ds_api.square_dunning`. 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 |
— |
/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 |
/v1/sales/items
Datastream
Line items across many sales, or across a product (batch)
Two reads in one endpoint. **At least one of `sale_ids` or `product_id` is required** — without a narrowing filter this would be a dump of every line item in the key's scope. `sale_ids` is the batched form of `/v1/sales/{sale_id}/items`: pass 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 pages that otherwise fire two requests per sale after listing them. Behaviour is unchanged from before `product_id` existed. Registered ahead of `/v1/sales/{sale_id}` so the literal path `items` is not swallowed by that route's `sale_id` parameter. `product_id` answers the other direction — "which clients bought MBO product X in this window?" — for a caller that has no sale ids up front. **`date_from` and `date_to` are both required alongside `product_id`** (400 otherwise): a sale-id list is self-bounding, a product id is not, and a popular pass spans years of rows. The date range is *not* required when `sale_ids` is the narrowing filter. Every row carries `transacted_at` (the datetime off the sale header, joined on site + sale id), so a roster read does not need a follow-up `/v1/sales` call for the clock time. `sale_date` remains the date-only column on the line itself. 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 total is simply `SUM(total_amount)`. Pass `exclude_refunds=true` to drop returned lines entirely; the default is `false`, so refunds are included and existing callers see no change. The read-after-write overlay (pending `POST /v1/purchases` rows) applies to the `sale_ids` form only — it is keyed by sale id and has no way to find a pending line by product.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
sale_ids (query) |
No | string |
Comma-separated sale ids |
product_id (query) |
No | string |
Comma-separated MBO product ids |
date_from (query) |
No | string (date) |
Inclusive lower bound on sale_date. Required with product_id. |
date_to (query) |
No | string (date) |
Inclusive upper bound on sale_date. Required with product_id. |
client_id (query) |
No | string |
— |
exclude_refunds (query) |
No | boolean |
true drops lines with is_returned = true. |
/v1/sales/items/summary
Datastream
Per-product rollup of line items in a date window
The same filters as `/v1/sales/items`, grouped per product, so a list screen showing twenty posts is one call rather than twenty roster reads. Rows are `{site_id, product_id, purchases, units, distinct_clients, total_amount, first_sale, last_sale}`. `date_from` and `date_to` are **both required** — the rollup is always windowed; a grouped scan of every line item ever sold is not a summary. `product_id` is optional: omit it to rank every product sold in the window, pass it (comma-separated, max 100) to restrict the rollup to the ones you care about. `site_id` is part of the grouping, not decoration: MBO product ids are per-site sequential and collide across studios, so a multi-site key gets one row per (site, product) rather than two studios silently merged under one id. Sign convention: `units` is `SUM(ABS(item_quantity))` because a returned line carries a negative quantity and would otherwise cancel a real purchase out of the count. `total_amount` is `SUM(total_amount)` raw — that column is already signed and net of discount, so a refund correctly pulls revenue down. `purchases` counts line rows; `distinct_clients` is the roster size. Internal QA/demo accounts (`ds_enriched.config_excluded_clients`) are excluded, matching every other aggregate in this service. Responses are `Cache-Control: no-store` and capped at 5000 rows.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
date_from (query) |
Yes | string (date) |
— |
date_to (query) |
Yes | string (date) |
— |
product_id (query) |
No | string |
Comma-separated MBO product ids |
client_id (query) |
No | string |
— |
exclude_refunds (query) |
No | boolean |
— |
/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 |
/v1/sales/{sale_id}/refund
Datastream
Return a sale in Mindbody, and refund Square when that sale was charged
Staff refund from Rally's Profile timeline. Mindbody `POST /sale/returnsale` is the system of record for the items. If `ds_api.square_payment` has a completed charge for this sale, Square is refunded after the return lands — never before, and never when the ledger has no row (method 19 is a label, not a processor). Square is inspected before ReturnSale when a ledger row exists, so a down card processor does not take the pass off the client and leave the charge. A Square failure after Mindbody returned is a 503 that names both outcomes — never a bare success. `test` defaults true. Send `test: false` to commit. Scope is `purchase:write` (the same grant that already sells). `site_id` is required. See docs/VERIFY.md §45.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
sale_id (path) |
Yes | string |
— |
site_id (query) |
Yes | string |
— |
/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 |
— |
/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` — and `transacted_at`, the header's datetime joined on site + sale id (`sale_date` on the line itself is date-only).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
sale_id (path) |
Yes | string |
— |
/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 10
/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. **`first_month_discount`** mints a one-use Mindbody promo (`Amount`, `NumberOfAutopays: 1`, scoped to the contract's pricing options), sells with it, then deactivates. `confirm_amount` must be the catalog first payment minus that discount (a few dollars of tax slack). A promo that would discount every autopay is refused. Do not send `promo_code` at the same time. **Payment sources:** `use_account_credit` (draws on the client's Mindbody house account — Payment Method Account) or `stored_card` (charges the card on file). `UseAccountCredit` will open a tab if the balance is short; a real purchase therefore `409`s unless `account_balance` covers the first payment. Fund credit first with `POST /v1/payments/square/subscriptions/mbo-credit`. 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same key. |
site_id (query) |
Yes | string |
The one 32-character Datastream site id this sale is made against, from `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one id** is accepted here — a list is a 400. Required because the site used to be inferred from whichever catalog row matched the item id first. Mindbody ids are per-site sequential and collide across sites, so for a key scoped to more than one site that could be another studio's row — and the sale landed on that studio's books with a response indistinguishable from a correct one. A sale names its site. Still checked against the key's own scope, so it can only ever narrow: an id outside the key's sites is a 403, not a 400. |
/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. |
q (query) |
No | string |
Free-text search over `package_name` (case-insensitive substring). Minimum 2 characters; shorter is a 400. Composes with the other filters by AND, so `?q=intro&sell_online=true` narrows both ways. `description` is not searched — it is empty on every service row. |
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 |
— |
/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. `first_payment_total` is Mindbody's own figure and is PRE-discount on contracts with a built-in first-autopay discount ("30 Days for $30" reads 90.19 there and 30.00 on Mindbody's contract screen). Every row therefore also carries `first_charge_amount` / `first_charge_tax` / `first_charge_total` (day-one charge from the contract terms net of `built_in_discount`) — quote and confirm from those. `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) |
— |
/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 |
— |
/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. |
/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 |
|---|---|---|---|
q (query) |
No | string |
Free-text search by name or product code — matches `name`, `product_id` and `barcode_id` (case-insensitive substring across all three). Minimum 2 characters; shorter is a 400. Composes with the other filters by AND, so `?q=mat&category_id=36` narrows both ways. `description` is not searched — it is empty on every product row. |
category_id (query) |
No | string |
— |
sell_online (query) |
No | boolean |
— |
discontinued (query) |
No | boolean |
— |
modified_since (query) |
No | string (date-time) |
— |
/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 |
— |
/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) |
— |
/v1/promo-codes/validate
Datastream
Validate a typed promo code and return what it is worth
Case-insensitive lookup of one currently-redeemable code (active and inside its date window). Splits MBO's Percent/FlatRate pair into `percent_off` / `amount_off` so a register can reprice without knowing that spelling. 404 if the code is missing, inactive, or expired.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
code (query) |
Yes | string |
— |
/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 12
/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 |
— |
/v1/staff
Datastream
Create a staff member
Creates a Mindbody staff record via `staff/addstaff`. Requires `staff:write`, and is served only where the deployment has writes enabled. **The record has no login, and no API can give it one.** Mindbody's `addstaff` has no username or password field; a login can only be set in Manager Tools → Staff. `POST /v1/staff/{staff_id}/permissions` refuses a staff member without one, so the sequence is create here → set the login by hand → assign the permission group. The response says so in `next_step`. `class_teacher` and `appointment_instructor` default to **false** rather than to Mindbody's own default: the first consumer of this endpoint creates an identity for an app, and a staff member who can teach appears in every teacher picker in the business. `site_id` is required (not just a narrowing filter): there is no existing mirror row to read tenancy off before the staff member exists, so the caller's own site scope is checked directly against it.
| 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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same key. |
site_id (query) |
Yes | string |
The 32-character Datastream site id to create the staff member in. Must be one of the key's own sites — anything else is a 403. |
/v1/staff/permission-groups
Datastream
The site's Mindbody Role names
The permission-group names as they read in Manager Tools → Staff → **Role**. Requires `staff:read` or `staff:write`, not `raw:read`. Mindbody has no endpoint that lists a site's groups — `GET /staff/staffpermissions` reads one staff member. This catalog is the Flow business's Role dropdown, captured 2026-09-03 from Manager Tools, so a console can render a picker without hardcoding the names. It is **not** a live Mindbody read and will drift if a group is renamed upstream. `site_id` is still required (the key must name a site it holds) even though every Flow site shares this list today.
| 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. |
/v1/staff/active
Datastream
The site's staff roster as Mindbody has it right now
The **live** active-staff roster, straight from Mindbody. Requires `staff:read` or `staff:write`. Use `GET /v1/staff` for a directory — it reads the mirror and costs nothing. Use this one when the question is *"who still works here?"*, because the mirror genuinely cannot answer it: Mindbody returns **only active staff** and carries no "was deactivated" flag, so absence from the response is the only signal that someone left. `ds_mbo.staff` is upserted by the sync, which means a departed staff member keeps `active = true` there indefinitely. Measured 2026-08-19 for the Flow site: **205 mirror rows marked active against 198 live**, and 4 of the 7 extras were class teachers whose rows had not been touched for three weeks. A consumer reconciling a contact list or a substitute pool against the mirror keeps asking people who are gone. Two further reasons this exists rather than a mirror filter: `/v1/staff` exposes no phone number at all, and this response carries one per staff member. **Behind `staff:read` or `staff:write`, not `raw:read`.** `raw:read` is held by every read consumer including the public website, and this response carries staff mobile numbers and e-mail addresses. `staff:write` still opens it. `class_teacher=true` filters the result **after** the Mindbody call; Mindbody has no filter for it. One page at `limit=500`, un-paginated: the largest roster in the business is 201. `no-store`. A cached roster is a message to someone who left.
| 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. |
class_teacher (query) |
No | string |
true, false |
/v1/staff/session
Datastream
Prove a Mindbody staff login
Verifies a Mindbody staff email + password via `/usertoken/issue` and returns who that login is. Used by Rally (and anything else that is a staff console) so people sign in with the same credentials as Portal. The token is discarded — this is identity, not a Mindbody session. Teachers are refused. A login gets a 200 when Mindbody reports `User.Type` Admin or Owner (Portal's `staff_type` gate), or when the permission group is one of Super Admin, Support Staff, Webmaster, Network Owner/Manager, Flow Manager, Assistant Manager. Admin and Owner tokens always have `User.Id` 0. That is not a usable `StaffId` — permissions are skipped unless the mirror can resolve a real staff id from the email. Tries each site in the key's scope that has an `mbo.siteId` until one accepts, unless `site_id` is named. Does **not** require `ENABLE_WRITES` or `staff:read`. The password is the credential. The stored password is never returned.
/v1/staff/{staff_id}
Datastream
Edit an existing staff record
Updates contact details on a Mindbody staff record via `updatestaff`. Requires `staff:write`, and is served only where the deployment has writes enabled. In practice this is the **phone fix**: a teacher says the number the studio holds for them is wrong, and this writes the correction back to Mindbody, which is the system of record every other copy converges from. Correcting it anywhere else produces a value the next sync overwrites — `ds_mbo` in particular has exactly one writer, the sync, so a patch written there is erased by the next pull. **Genuinely partial.** Only the fields named in the body reach Mindbody, which treats an absent key as "leave it alone". That matters most on the phone fields: sending an empty value for a number the caller never mentioned would wipe it. At least one field is required. An empty body would otherwise be a metered Mindbody call that changes nothing and answers `200`. **Does not write to Datastream** — the mirror reflects the new number on its next sync. A consumer that needs it immediately should use the value it just sent.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
staff_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. Reusing a key for a *different* request body returns `409` rather than replaying the first response. A request still in flight also returns `409` — retry once it completes. If the store backing this is unavailable, the request returns `503` rather than running unguarded; retry with the same 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. |
/v1/staff/{staff_id}/permissions
Datastream
Read a staff member's permission group
What this staff member is allowed to do, read **live from Mindbody** (`/staff/staffpermissions`) rather than from the mirror. `ds_mbo.staff` carries no permissions and nothing syncs them, so there is no mirrored answer to give — the same reason `GET /v1/schedule/{class_id}/services` is a live read. Returns **one staff member's** group. Mindbody has no endpoint that lists the permission groups a business has defined. `GET /v1/staff/permission-groups` is our catalog of the Flow Role dropdown for a picker; the matching `POST` still takes a group *name*. Requires **`staff:read` or `staff:write`**, not `raw:read`. `raw:read` is held by every read consumer including the public website; enumerating who can do what in a studio's Mindbody does not belong on that key. `staff:read` lets a console render this without also creating staff. `staff:write` still opens it. Served `no-store`: this is the answer to "what can they do right now", usually read immediately before changing it. Needs Mindbody staff credentials for the site, so a site without them returns 503. Not gated on `ENABLE_WRITES` — it changes nothing.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
staff_id (path) |
Yes | string |
— |
site_id (query) |
Yes | string |
The 32-character Datastream site id. Must be one of the key's own. |
/v1/staff/{staff_id}/permissions
Datastream
Assign a staff member's permission group
Puts a staff member in a named permission group via `staff/updatestaffpermissions`. Requires `staff:write`, and is served only where the deployment has writes enabled. **The staff member must already have a login.** Mindbody rejects the call outright otherwise, and that rejection passes through as a 422 carrying Mindbody's own wording. The group is named. Mindbody has no list API — `GET /v1/staff/permission-groups` is our catalog of the Flow Role dropdown, not an upstream read. A name that does not exist in Manager Tools is a 422. POST rather than PUT: Mindbody's verb takes a group name, not a representation of the permissions, so there is no document for a PUT to replace.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
staff_id (path) |
Yes | string |
— |
site_id (query) |
Yes | string |
The 32-character Datastream site id the staff member belongs to. Must be one of the key's own sites — anything else is a 403. Required because Mindbody staff ids are per-site sequential and collide across sites, so an id alone does not identify a tenant. |
/v1/staff/{staff_id}/photo
Datastream
Authored staff profile photo
Bytes Rally stored for this staff member in `ds_api.staff_photo`. Mindbody Public API v6 can read `imageUrl` and cannot write one, so a photo change cannot live on the mirror. `site_id` is required — staff ids collide across sites. `raw:read` or `staff:read`. Without sql/043 this 404s rather than 500ing.
| 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. |
t (query) |
No | string |
Cache-buster. Ignored by the handler. |
/v1/staff/{staff_id}/photo
Datastream
Set a staff profile photo
Stores a JPEG, PNG, or WebP on `ds_api.staff_photo` and overlays `image_url` on `GET /v1/staff`. Does not call Mindbody — UpdateStaff has no image field. Requires `staff:write` and ENABLE_WRITES. Body is base64, max 600KB decoded. Rally resizes first.
| 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. |
/v1/staff/{staff_id}/photo
Datastream
Clear an authored staff photo
Drops the `ds_api.staff_photo` row so `GET /v1/staff` falls back to Mindbody's mirrored `imageUrl`. Requires `staff:write`.
| 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. |
/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 · identity 3
/v1/keys/self/mbo-identity
Datastream
Which Mindbody staff user this key sells as
The acting Mindbody login on the **calling key** — the staff user Mindbody stamps as "Sold By" on every sale that key makes. Requires `identity:write`. `configured: false` means the key falls back to its site's shared login, which is the default and why most keys' sales are indistinguishable in a studio's reports. **The stored password is never returned**, here or anywhere. There is no key id in this path, and no parameter that takes one: the only key any verb here can reach is the one that authenticated the request.
/v1/keys/self/mbo-identity
Datastream
Set the Mindbody login this key sells as
Stores a Mindbody staff login on the **calling key**, so its sales are stamped "Sold By" that staff member rather than the site's shared API user. Requires `identity:write`, and is served only where the deployment has writes enabled. `identity:write` is deliberately separate from `purchase:write`: a key that may sell should not thereby be able to change who it sells as. **The login is verified before it is stored.** It must mint a real Mindbody token against `site_id` first; a rejection is a 422 carrying Mindbody's own wording and **nothing is written**. This exists because a password wrong by one character otherwise stores cleanly and fails hours later, mid-sale, at a kiosk. **Replacing an existing identity requires `replace: true`** — a 409 otherwise. Overwriting re-attributes every future sale, and finding that out from a payroll report is the failure this prevents. **The key must be bound to exactly one site** — a 409 otherwise. The stored login is looked up by key id with no site in the query, so one login would be used for every site the key holds, and Mindbody logins are per site. Setting an identity drops the key's cached Mindbody tokens, so the change takes effect on the next sale rather than after Mindbody's 24-hour token expiry.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
No | string |
Accepted for consistency with the rest of `/v1`, but the site the login is verified against comes from the request body. |
/v1/keys/self/mbo-identity
Datastream
Revert this key to its site's shared login
Removes the acting login from the **calling key**, so its sales go back to being stamped with the site's shared API user. Requires `identity:write`, and is served only where the deployment has writes enabled. This is the way back when a Mindbody staff login is disabled or its password changes: every sale that key makes fails until it is either fixed or cleared, and clearing it should not require a database session. Returns 200 with `cleared: false` when nothing was set, rather than a 404 — the caller asked for a state and that is the state it gets.
Datastream · locations 3
/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. Each row also carries `content` — hand-authored fields Mindbody has no equivalent for (`ds_api.location_content`, sql/035), including the populated `slug` and a Google Maps URL. It is `null` where nobody has authored any, and `null` throughout on a deployment without sql/035.
| 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 |
— |
/v1/rooms
Datastream
Bookable rooms at the site
`ds_mbo.resource` — `{room_id, name, location_id}` — plus `capacity`. **`capacity`** is how many people fit in the room. MBO has no such field: `/site/resources` returns `{Id, Name}` and nothing else, so this is merged in code from `ds_api.room_capacity` rather than mapped off the mirror. It fails soft, so `capacity` is `null` on every room until `sql/040` is applied. Afterwards `null` still means **nobody has authored one** — distinct from `0`, which means the room cannot be booked. **Read only for now.** There is no write endpoint yet, so `/publisher/room-capacities` on the Portal is still where staff change a capacity, and a change there does **not** reach this table. Expect the two to disagree until the write path lands. `location_id` exists because the sync now pulls rooms one location at a time (itflow_datastream#50). MBO's `/site/resources` returns `{Id, Name}` and no location, so the pairing comes from which location was asked for rather than from the response. **A NULL `location_id` means unknown, not "no location".** Rows written before that change, and rooms at a site whose location list could not be read, carry none — and are therefore absent from a `location_id`-filtered result. An unfiltered call is still the way to see every room. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
location_id (query) |
No | string |
Mindbody's numeric location id, as returned by `GET /v1/locations`. Rooms with no recorded location are excluded. |
modified_since (query) |
No | string (date-time) |
— |
/v1/rooms/{room_id}/capacity
Datastream
Set or clear how many people fit in a room
The only place a room's capacity can be written. Mindbody has no capacity on a resource — `/site/resources` returns `{Id, Name}` — so there is no upstream to proxy to, and this writes `ds_api`, the schema this service owns. `site_id` is required in the body rather than inferred from the key. Room ids are per-site sequential, a key may hold several sites, and a request about the wrong studio should fail before it changes anything. The value is checked against the key's site scope, never trusted. `capacity: null` is a real value — "someone looked at this room and left it blank" — and it **overwrites**. The field must be present: omitting it is a 400, so clearing a capacity is always deliberate. Requires `config:write`, deliberately not implied by `config:read`, plus the `ENABLE_WRITES` kill switch. No `Idempotency-Key` — setting the same number twice is the same result. The Portal's /publisher/room-capacities writes its own separate table and the two do **not** sync. Pick one and stay there.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
room_id (path) |
Yes | string |
Mindbody resource id |
Datastream · clients 18
/v1/clients/{client_id}/has-credits
Datastream
What a client can currently pay with
Everything this client holds that is valid on a date, sorted by what kind of booking it pays for. Requires `raw:read`; writes nothing. The generic credit question. **No event, class or booking is named** — it answers "what do they hold", not "is that enough for X". Deciding a specific booking belongs to that booking's endpoint: `POST /v1/events/{event_id}/enrollments` calls this same logic and then intersects `client_event_services` with that event's own pricing options. **Three client-pack endpoints exist and they are easy to confuse:** | | `/services` | `/credits` | this | |---|---|---|---| | source | mirror | mirror | **live Mindbody** | | freshness | lags a sync window | lags a sync window | now | | shape | raw rows | usable rows | three buckets by booking type | | cached | yes | 300s | never | Use `/services` to list a client's packs and `/credits` for a "My Passes" view. Use this one to decide a booking — a pack sold two minutes ago has to count, and the mirror can be a sync window behind. **Which id each bucket carries differs, and it matters.** `client_event_services` holds catalog **product** ids, because that is what an event's pricing-option list names. `client_services` holds the client's own **service** ids, which is what `addclienttoclass` takes. **`NO_CREDITS` is a 200**, not an error: "holds nothing" is a successful answer to the question asked. A pack's validity depends on a date — an unused Day Pass skips its active/expiry window because its clock has not started, while every pack must still not have expired. `check_date` defaults to today in the studio's timezone; set it to ask whether a pack will still be good on a future date.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
check_date (query) |
No | string |
YYYY-MM-DD. Defaults to today in the studio timezone. |
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. |
/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`, `booking_suspended`, `suspension_start`, `suspension_end`, `address`, `address2`, `city`, `state`, `postal_code`, `country`, etc. `booking_suspended` / `suspension_start` / `suspension_end` lift `$.entity.suspensionInfo` (MBO `ClientSuspensionInfo`) — a client booking freeze, not contract `AutopayStatus`. Nested paths are unverified (docs/VERIFY.md §43); they are null when Scheduling Suspensions is off or the object is empty. 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 |
— |
/v1/clients
Datastream
Create a Mindbody client (kiosk walk-in)
Adds a client at the key's site via Mindbody AddClient. Used by the lobby iPad form — the device token is the authority, not an Auth0 identity. Duplicate email is treated as success (the existing client is returned). Requires `checkin:write` and ENABLE_WRITES.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
— |
/v1/clients/{client_id}
Datastream
Edit a Mindbody client's name, email, or phone
Live UpdateClient. Staff correction of the studio record — the mirror follows on the next sync. Genuinely partial: only named fields are sent. Requires `checkin:write` and ENABLE_WRITES.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
site_id (query) |
Yes | string |
— |
/v1/clients/search
Datastream
Typeahead search — name, email, phone, or client_id in one `q` param
For a search-as-you-type box, not a list page: capped small (default and effective limit 8) and always ordered by name. `q` matches against name, email, mobile_phone, client_unique_id, and client_id together, so the caller doesn't need to know which field the user typed into. Reads the enriched mirror (real columns, not the raw MBO JSON), so it carries the same freshness caveat as `/v1/clients?email=`: refreshes on the enriched transform's schedule, not live.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
q (query) |
Yes | string |
— |
site_id (query) |
No | string |
Narrow within the key's site scope |
limit (query) |
No | integer |
— |
/v1/clients/{client_id}/profile
Datastream
One-request identity + holdings for a profile first paint
Composes the existing client, contracts, services, enrollments, and sale-header reads so a profile does not need 4–5 parallel `/v1` calls. No new schema — each list is the same ResourceSpec as its dedicated route. Sale items are omitted (headers only). Requires `raw:read` and `enriched:read`. Enrollments and sales are all-time (paged at 1000). Contracts 50 and services 100 stay first-paint capped. `date_from` / `date_to` optionally bound enrollments and sales. `?limit=` does not change those. Item envelope data: `{ client, contracts, services, enrollments, sales, enrollments_window: { date_from, date_to }, visits_total, last_visit, spend_total }`. `enrollments_window` is null/null when unbounded. `visits_total` is an unbounded COUNT of signed-in `class_visit` rows. `last_visit` is an unbounded MAX of signed-in `start_time` (null when they have never signed in). `spend_total` is an unbounded SUM of `ds_enriched.sales.sale_total` (do not sum sale_items and sale_payments together). Each nested row carries `source`. Cache-Control is 60s unless a read-after-write overlay applied, then `no-store`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
date_from (query) |
No | string (date) |
Optional enrollment and sales window start (YYYY-MM-DD). Omit for all-time. |
date_to (query) |
No | string (date) |
Optional enrollment and sales window end (YYYY-MM-DD). Omit for all-time. |
exclude_membership_cycles (query) |
No | boolean |
Drop autopay-cycle rows from the services block. See the /services endpoint. |
site_id (query) |
No | string |
Narrow within the key's site scope |
/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) |
— |
/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. Each row carries `entitlement_kind` and `contract_id` — see `/services`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
expires_after (query) |
No | string (date) |
— |
exclude_membership_cycles (query) |
No | boolean |
— |
/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). **Membership cycles.** MBO writes a new row every autopay billing cycle of a recurring membership, so a client on a monthly Unlimited has dozens of rows named "Unlimited" next to their genuine one-off passes. `entitlement_kind` tells them apart — `membership_cycle` (granted by a contract, with that contract in `contract_id`), `standalone_pass` (bought on its own), or `unknown` (no matching sale row to classify against; 17% of rows, concentrated in pre-2025 history). Pass `exclude_membership_cycles=true` to drop the cycles and keep both other kinds. It is opt-in: the raw cycle history is what a diagnostic holdings view wants, and an active membership's *current* cycle is a genuinely usable entitlement.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
is_current (query) |
No | boolean |
— |
active_from (query) |
No | string (date) |
— |
exclude_membership_cycles (query) |
No | boolean |
— |
/v1/clients/{client_id}/services/{client_service_id}
Datastream
Edit one pass (start, expiration, remaining credits)
Proxies Mindbody `UpdateClientServices`. The mirror is not written; the next sync picks the row up. `test` defaults true: the handler lists live client services and does not post. Send `test: false` to commit. Editable fields: `start_date` (ActiveDate), `expiration_date` (ExpirationDate), `remaining_count` (Remaining visits left). `duration_days` is a convenience that sets expiration from `start_date` (`start + duration_days`); do not send it with `expiration_date`. Remaining is MBO `Remaining`, not the original purchased `Count` (docs/VERIFY.md §44). Requires `purchase:write` and `ENABLE_WRITES`. Dates are studio-local `YYYY-MM-DD`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
client_service_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. |
/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) |
— |
/v1/clients/{client_id}/contracts/hold
Datastream
Pause (suspend) a client's membership
Proxies Mindbody `SuspendContract`. Same match + preview/commit shape as cancel. `test` defaults true. `end_date` is preferred over `duration_days` / match-text parsing. When a Square subscription is linked to this client (and the key holds `payment:write`), a commit also pauses Square billing so the member is not charged through the hold. A Square failure after the Mindbody hold landed is returned as `billing_note`, not a 500. Requires `contract:write` and `ENABLE_WRITES`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_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. |
/v1/clients/{client_id}/contracts/billing-date
Datastream
Move the next membership billing date
Square-billed memberships move the Square renewal (billing-anchor for monthly; pause-until-resume otherwise). Mindbody-billed memberships PATCH the next `UpcomingAutopayEvent` via `UpdateClientContractAutopays`. `ScheduleDate` is an extra (VERIFY §46) and may no-op — a commit re-lists and says so. Empty upcoming + no Square subscription is a 409, not a toast. `test` defaults true. Requires `contract:write` and `ENABLE_WRITES`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_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. |
/v1/clients/{client_id}/contracts/cancel
Datastream
Terminate a client's membership
Proxies Mindbody `TerminateContract`. Lists live contracts first (`GetClientContracts`) so the instance id is real. `test` defaults true and stops after the list. Requires `contract:write` and `ENABLE_WRITES`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_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. |
/v1/clients/{client_id}/contracts/{client_contract_id}/autopays
Datastream
Past and upcoming autopays for one membership
Upcoming rows are live Mindbody `GetClientContracts.UpcomingAutopayEvents`. Past rows are `ds_enriched.sale_items` for that client_contract instance (VERIFY §37). Also returns the site's locations and payment methods so a consumer can edit without a second hop. Scope `raw:read`. `Cache-Control: no-store`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
client_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. |
/v1/clients/{client_id}/contracts/{client_contract_id}/autopays
Datastream
Edit one upcoming membership autopay
Proxies Mindbody `UpdateClientContractAutopays`. Official fields are amount and the AutopayStartDate/AutopayEndDate window (the payment's current `original_schedule_date`). `schedule_date`, `location_id`, and `payment_method` / `payment_method_id` are forwarded as extras (VERIFY §45); Mindbody may ignore them. The handler re-lists live events after a commit. `test` defaults true. Requires `contract:write` and `ENABLE_WRITES`. Dates are studio-local `YYYY-MM-DD`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_id (path) |
Yes | string |
— |
client_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. |
/v1/clients/{client_id}/stored-card
Datastream
Live Mindbody card on file for a client
The card Mindbody holds on the account (`ClientCreditCard`): `last4` and `card_type` only. Card expiration is not returned (VERIFY §5). A live Mindbody read — `ds_mbo.client_transaction` is cron-only and has been measured days behind a first-time Credit Card sale, so Rally cannot infer "card on file" from that table for a new client. Empty `last4` / `card_type` means none on file, not a 404. A client outside the key's scope is a 404 and never reaches Mindbody. Scope is `raw:read`. `Cache-Control: no-store`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
client_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. |
/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) |
— |
Datastream · classes 2
/v1/courses
Datastream
Courses — the enrollment catalog a publisher picks from
ds_mbo.class_description — what Mindbody's back office calls a COURSE under Services & Pricing → Enrollments, where each program ("Event Single-Day", "Retreats", "Teacher Trainings") holds a list of courses and a course is what a studio schedules an enrollment from. MBO's API noun for the same record is a class description. Do not confuse it with `/v1/class-descriptions/{class_schedule_id}`, which is a projection of ds_enriched.classes for CLASS copy. Different table, different question. PREFER `program_ids`, WITH A `site_id`. Measured on live data: within one site, id 23 carries both "Event Single-Day" (319 courses) and "Workshops" (243), and id 41 carries both "Event Multi-Day" and "Workshop Series". A course stores the program name as it was when the row synced, so a renamed program leaves older rows under the old label — the id survives a rename and the name does not. `program_names` therefore returns fewer rows than it should whenever a program has been renamed. It stays available for a caller holding only a label (the names match `program` on `/v1/events`), but reach for the id first. Ids are per-site, so pair `program_ids` with `site_id` unless the key is already scoped to one site. Both accept a CSV, because the question a publisher asks spans programs. `session_type_id`, `category_id`, `subcategory`, `subcategory_id`, `image_url` and `is_active` are mapped from MBO's ClassDescription contract but **not yet observed populated** — the enriched transform reads only the name paths. See docs/VERIFY.md §45. Prefer the mirror's own soft-delete flag over `is_active`; the query builder already applies it. `source` is the literal `"mbo"` until sql/039 adds the column. Carries `source` as upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
program_ids (query) |
No | string |
CSV of program ids, max 25. Preferred — survives a program rename. Pair with site_id |
program_names (query) |
No | string |
CSV of program names, max 25. Misses rows when a program was renamed |
session_type_name (query) |
No | string |
— |
category (query) |
No | string |
— |
modified_since (query) |
No | string (date-time) |
— |
/v1/courses/{course_id}
Datastream
One course
ds_mbo.class_description by id. Carries `source` as upstream provenance. See /v1/courses for the unverified fields.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
course_id (path) |
Yes | string |
— |
Datastream · reports 9
/v1/reports/sales-daily
Datastream
Daily revenue by location and category (cash-accounting adjusted)
Each row also carries `paidQuantity`, units on line items that charged something, counted per line item (the daily rollup cannot tell free passes from a paid one on the same day). 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 |
— |
/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 |
— |
/v1/reports/membership-activity
Datastream
Membership events — new, renewed and cancelled contracts by day
The event log that /v1/reports/membership-changes is not. membership-changes is a point-in-time snapshot (who holds an active membership as of one date); this endpoint reads the dates already on every ds_enriched.client_contracts row as events, for a "New" / "Cancellations" view over a date window. `changeType` is one of: - `new` — a contract whose start_date falls in the window and which is the client's first contract at that site (no row for the same site_id + client_id with an earlier start_date). eventDate = start_date. - `renewed` — a contract starting in the window where such an earlier contract does exist (autopay roll-overs materialised as a fresh row, and lapsed members returning — the data does not distinguish them). eventDate = start_date. - `cancelled` — a contract whose termination_date falls in the window. eventDate = termination_date. NOT reported: `expired`. An end_date passing without a termination_date is ambiguous here — autopay contracts renew past their end_date — so it would count live members as expirations. One row per (siteId, locationId, locationName, eventDate, changeType, membershipType); `clients` is COUNT(DISTINCT client_id). Excludes config_excluded_clients in every branch. `location_id` filters on the contract's own location. `date_from`/`date_to` are REQUIRED, date_from <= date_to, and the window may span at most 366 days — 400 otherwise.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
date_from (query) |
Yes | string (date) |
— |
date_to (query) |
Yes | string (date) |
— |
location_id (query) |
No | string |
— |
/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. Excluded: cancelled classes, and dates marked removed (is_removed) - deleted or moved in Mindbody, as /v1/schedule already leaves them out. Counting those would report classes that never ran, and a moved event on both its old and its new date. 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 |
— |
/v1/reports/student-visits
Datastream
Students ranked by signed-in visit count in a window
One row per client with a signed-in visit count inside a required date window, optionally scoped to location / teacher / class name, with an exclusive "more than N" floor (`min_visits`). Computed live from ds_mbo.class_visit so the window range-scans idx_siteId_startDateTime (same index as /reports/new-students and /reports/recent-visitors). `staff_name` matches the staff display name or the usual Mindbody abbreviation (Adam Horowitz = Adam H.) by resolving ds_mbo.staff and filtering visits on entity.staffId. Class name still INNER JOINs ds_enriched.classes on (site, class_id). `date_from`/`date_to` are REQUIRED, date_from <= date_to, and the window may span at most 90 days — 400 otherwise. Name/email/phone join ds_enriched.clients after LIMIT. Excludes config_excluded_clients. Ordered by visitCount descending, at most 5000 rows.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
date_from (query) |
Yes | string (date) |
— |
date_to (query) |
Yes | string (date) |
— |
location_id (query) |
No | string |
Comma-separated Mindbody location ids |
staff_name (query) |
No | string |
Display name or abbreviation; resolved to staff id |
class_name (query) |
No | string |
— |
min_visits (query) |
No | integer |
Exclusive floor — HAVING COUNT(*) > min_visits |
/v1/reports/new-students
Datastream
New students by first-ever signed-in visit, per day and location
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. "New student" means a client's FIRST-EVER signed-in visit — lifetime, not first-within-the-window. A returning student who visits inside the window is not counted, however long the gap since their last visit. (An earlier version ranked visits only inside the window, which counted every returning student as "new" once per window.) Two stages: (1) one row per client with MIN(start) over signed-in visits inside [date_from, date_to] — a range scan of idx_siteId_startDateTime bounded by the window; (2) a NOT EXISTS anti-join back into class_visit for any signed-in visit before `date_from`, a point seek per in-window client on (_siteId, _clientId). Grouped by the date and location of that first visit. Excludes config_excluded_clients. `date_from`/`date_to` are REQUIRED — 400 without both. `location_id`, if given, scopes both stages to that location: a client's first-ever signed-in visit *at that location*.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
date_from (query) |
Yes | string (date) |
— |
date_to (query) |
Yes | string (date) |
— |
location_id (query) |
No | string |
— |
/v1/reports/recent-visitors
Datastream
Clients ranked by most recent signed-in class visit
One row per client, ordered by last signed-in visit descending. Replaces the retired v1 client_last_visits mart (docs/DS-ENRICHED-V2.md, "Gone in v2") for an All Contacts list sorted by last visit. Computed live from ds_mbo.class_visit: GROUP BY client_id, MAX(_startDateTime), range-scanning idx_siteId_startDateTime. Signed-in only, future bookings excluded, test/demo accounts dropped via config_excluded_clients. Default window is the last 30 days so the aggregate can range-scan idx_siteId_startDateTime on ds_mbo.class_visit (same index as /reports/new-students). date_from/date_to override. Name/email/phone come from a LEFT JOIN to ds_enriched.clients after LIMIT. Paginated with limit/offset; total_count is distinct clients in the window.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
date_from (query) |
No | string (date) |
— |
date_to (query) |
No | string (date) |
— |
limit (query) |
No | integer |
— |
offset (query) |
No | integer |
— |
/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) |
— |
/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
/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 · auth 14
/v1/auth/login
Datastream
Start a browser login through Auth0
Browser-facing (no bearer key — the whole /v1/auth surface is mounted outside the API-key middleware). Signs a short-lived state cookie (`flow_ds_auth_txn`) and 302s to the custom domain's Universal Login /authorize with `response_type=code`, `scope "openid profile email offline_access"` (the refresh token backs later passkey enrollment) and `prompt=login`. Auth0 is only the identity verifier here, exactly as on the legacy PHP site: the callback applies the business guards and issues this service's OWN session, discarding the Auth0 artifacts. `return_to` is allowlisted to https://api.fvmgt.com and https://staging.flowyogatx.com (plus localhost in development) — the open-redirect guard. Answers 503 when the AUTH0_* variables are unset.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
connection (query) |
No | string |
password, google, facebook, email |
login_hint (query) |
No | string (email) |
Prefills the login form. |
checkout_email (query) |
No | string (email) |
Pins the flow to a checkout's email; a different authenticated email ends in outcome=email_mismatch. |
return_to (query) |
No | string (uri) |
Where the callback 302s back to. Allowlisted; defaults to AUTH0_DEFAULT_RETURN_TO. |
site_id (query) |
No | string |
Optional studio context — the 32-hex Datastream site id or the MBO numeric id (e.g. 30313), validated against the active registry (unknown → 400). When set and the identity has no Flow client, the callback offers signup instead of a dead end. |
/v1/auth/pending-kiosk-return
Datastream
Latest lobby-kiosk join URL, if one is live
The API console calls this after a magic-verify dump. When a lobby QR is waiting, the response carries that unguessable short-lived `/j/` URL so the phone can be sent back. Empty `{url:null}` when nothing is live.
/v1/auth/callback
Datastream
Auth0 redirect target — verify, guard, and issue the first-party session
Not called by consumers directly: Auth0 sends the browser here after the Universal Login. Validates the signed state, exchanges the code at the custom domain's /oauth/token, verifies the RS256 ID token against the tenant JWKS (iss/aud/exp/nonce), then applies the guards ported from the legacy Auth0Flow::handleCallback and 302s to the flow's `return_to` with `?outcome=<code>` (and `email=` when the profile carried one). A LOGIN round emits (see the `auth` tag description for what each code means): `logged_in`, `bad_connection`, `no_email`, `email_mismatch`, `signup_required`, `passwordless_blocked`, `amr_blocked`, `pending_verification`, `auth0_error`, `exchange_failed`. Only `logged_in` sets the `flow_ds_session` cookie (400-day rolling expiry, HttpOnly, SameSite=Lax; Domain=.fvmgt.com + Secure in production). A LINK round (started by `GET /v1/auth/link`, told apart by the signed transaction cookie) skips the login guards entirely and emits `linked`, `link_email_mismatch`, or `link_failed` — the first-party session is untouched in every link branch. When the login flow declared a `site_id` and the verified identity has no Flow client, the two dead ends (`signup_required`, `passwordless_blocked`) instead redirect as `outcome=signup_required&signup_token=<signed blob>&email=` — a 15-minute HMAC-signed token carrying the verified email, connection, Auth0 sub, site, and any profile names, which `POST /v1/auth/signup` redeems. Without a site the legacy outcomes stand unchanged, and `amr_blocked`/`bad_connection` never convert. The account lookup resolves email → client through the same enriched index `/v1/clients?email=` uses, over ALL active sites — a login email is global, not per-tenant (the documented system-scope exception in src/auth/login-scope.ts). On `logged_in` the session records the first matched client's `client_id` and `site_id`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
code (query) |
No | string |
— |
state (query) |
No | string |
— |
error (query) |
No | string |
— |
error_description (query) |
No | string |
— |
/v1/auth/magic-link
Datastream
Email a first-party magic link
Step 1 of the first-party magic-link flow, replacing Auth0's Classic-UL-only {{ link }} links. Asks the tenant to send a passwordless OTP (`POST /passwordless/start` with `send: "code"` on the email connection); the tenant's email template — which we control — wraps that code in a link to `/v1/auth/magic-verify`. Answers 202 `{sent: true}` whether or not the address has an Auth0 user (existence is deliberately not leaked; Auth0's unknown-user 400 is masked and logged server-side). Rate-limited to 3 sends per address per 5 minutes.
/v1/auth/magic-verify
Datastream
Redeem a magic-link code for a first-party session
Step 2 — the URL the email template links to. Arrives from any device and mail client, so every defect ends in a friendly redirect rather than an error page. Redeems the OTP at the custom domain's /oauth/token (passwordless OTP grant), verifies the returned RS256 ID token against the tenant JWKS exactly like the /authorize callback (no nonce — the single-use OTP is the replay protection), then runs the same ported guards with the connection pinned to "email". 302 to AUTH0_DEFAULT_RETURN_TO with `?outcome=`: `logged_in` (Flow account found; the `flow_ds_session` cookie is set on this response, identical to the callback's), `passwordless_blocked` (no Flow account and no studio context), `signup_required` (no Flow account but a studio IS known — from `?site_id=` or the send's stash — with `signup_token=` for `POST /v1/auth/signup`), or `magic_link_invalid` (missing parameters, or Auth0 refused the code: wrong, expired, or already used). `email=` rides along whenever one is known.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
email (query) |
Yes | string (email) |
— |
code (query) |
Yes | string |
The OTP from the email (the template's {{ code }}). |
site_id (query) |
No | string |
Optional studio context (hex or MBO numeric). Unresolvable values are ignored with a log line — this URL arrives from an email client, so the graceful floor is the pre-signup outcome, not an error page. |
/v1/auth/passkey-token
Datastream
Prepare a logged-in browser to enroll a passkey
Requires the `flow_ds_session` cookie (401 otherwise) — works after any login, password or magic link. Auth0's own enrollment screens are disabled on this tenant; the browser runs the WebAuthn ceremony itself against Auth0's My Account API, and this endpoint supplies the two things only the server can: a database-connection identity to hang the passkey on, and a My Account access token. (a) Via the Management API (client credentials, tenant domain, token cached until expiry): if the session's user has no `auth0`-provider identity, a shadow Username-Password-Authentication user is created (same email, verified, random 32-char password) and linked under the primary — idempotent on repeat calls. (b) The session's stored refresh token (captured at login via offline_access, AES-256-GCM-encrypted in the cookie) is exchanged at the custom domain for a My Account API token scoped to create/read/delete:me:authentication_methods. If the tenant rotates refresh tokens, the successor is re-sealed into the session cookie on this response. No credential material ever passes through this service — the response only points the browser at Auth0.
/v1/auth/signup
Datastream
Create the Mindbody client a signup_required outcome asked for
Redeems a `signup_token` (minted by the callback or magic-verify when a VERIFIED identity had no Flow client and the flow declared a site). The browser contributes only names and an optional phone — the email, studio, connection and Auth0 sub stay server-signed inside the token. Creates the client via Mindbody's `POST /client/addclient` at the token's site through the same per-site staff-credential session layer bookings use, behind the same `ENABLE_WRITES` kill switch (403 when off). Mindbody's "email already in use" refusal is treated as success-equivalent: the existing client is looked up LIVE at that site and logged in; if it cannot be retrieved, 409. On success: sets the same `flow_ds_session` cookie a normal login issues (no refresh token — passkey enrollment answers 409 passkey_reauth_required until the next real login) and returns 201 with the /me-shaped body. The mirror will not carry the new client until its next sync; the session works regardless, but an immediate re-LOGIN before that sync still lands on signup_required (docs/VERIFY §25).
/v1/auth/profile
Datastream
Fill missing name/phone on the session's Mindbody client
After a login that already created the account (Google, Facebook, password, or magic link), the redirect may carry `missing=phone` (or first_name,last_name). This endpoint writes those fields through Mindbody `POST /client/updateclient`. Cookie session is required; 401 without. Behind ENABLE_WRITES.
/v1/auth/methods
Datastream
What login methods this account has
Session-cookie-authenticated (401 without). Reads the user via the Management API. `password` is deliberately not "a DB identity exists": shadow identities created for passkey enrollment carry a random password nobody knows (marked `user_metadata.shadow_identity` at creation), so a DB identity counts only when unmarked or when `user_metadata.password_set` is "true". Identities predating the marker can misreport password:true — docs/VERIFY §26. `email_otp` is constant true (passwordless is a tenant capability, not a per-user enrollment). `passkeys` counts type=passkey entries in the Management authentication-methods list.
/v1/auth/link
Datastream
Start linking a social identity to the logged-in account
Session-cookie-authenticated (401 without). 302s to the Universal Login in LINK MODE: the user re-authenticates at the provider to prove they control that identity, and the callback — instead of running the login guards — links it into the session's user via the Management API. The first-party session is untouched throughout; the Auth0-side session is discarded as in every other round (no offline_access, tokens discarded). Callback outcomes for a link round, 302 to return_to: `outcome=linked&provider=<google-oauth2|facebook>&email=` on success; `outcome=link_email_mismatch&email=<the social email>` when the provider identity's email differs (case-insensitive) from the session's — no link is made; `outcome=link_failed&provider=` when the Management link call fails.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
connection (query) |
Yes | string |
google, facebook |
return_to (query) |
No | string (uri) |
Allowlisted; defaults to AUTH0_DEFAULT_RETURN_TO. |
/v1/auth/unlink
Datastream
Remove a linked social identity
Session-cookie-authenticated (401 without). Unlinks the given provider's identity from the account via the Management API. The email/passwordless method always remains usable, so the only refusals are: the provider is not linked (400), or it IS the account's primary identity — the one it was created with — which cannot be unlinked from itself (409).
/v1/auth/password-setup
Datastream
Send the set-a-password email for this account
Session-cookie-authenticated (401 without). Ensures a database-connection identity exists (created shadow-marked if missing, same flow passkey enrollment uses), records `user_metadata.password_requested`, then has Auth0 send its branded change-password email via /dbconnections/change_password. Completion is invisible to this service (no Auth0 Action is installed), so GET /v1/auth/methods keeps reporting password:false for a shadow-marked identity even after the user sets a real password — the approximation is documented in docs/VERIFY §26.
/v1/auth/me
Datastream
Who the session cookie says the browser is
Reads only the `flow_ds_session` cookie — no Auth0 call, no database. Item envelope: `{authenticated, user}` where `user` is null when there is no valid session, else `{email, name, connection, email_verified, auth0_sub, client_id, site_id, logged_in_at}`. `logged_in_at` is UTC ISO-8601 — session metadata, not studio wall-clock data. CORS allows https://api.fvmgt.com (and localhost) with credentials, so the test console can call this cross-origin. Every authenticated call also RE-ISSUES the session cookie with a fresh 400-day expiry (rolling renewal — 400 days being the browser cap on cookie lifetime, so an active session never lapses).
/v1/auth/logout
Datastream
End the first-party session
Clears the `flow_ds_session` cookie and returns `{logged_out: true, auth0_logout_url}` — the tenant's /v2/logout URL the page can send the browser to for a full Auth0-side sign-out (the API never ends the Auth0 session itself; it only ever held its own).
CMS · ops 2
/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).
/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 8
/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.
/v1/settings/studio-names
CMS
Studio display-name overrides
The whole override map, one row per studio. `source_name` is the raw upstream name folded to lower case (Mindbody's "flow yoga georgetown"); `display_name` is what the console shows instead ("Georgetown"). Display-only — no filter or identifier changes. Requires config:read.
/v1/settings/studio-names/{source_name}
CMS
Set one studio's display name
Body `{ "display_name": "Georgetown" }`. Creates or replaces; the path name is folded to lower case before matching. Requires config:write.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
source_name (path) |
Yes | string |
— |
/v1/settings/studio-names/{source_name}
CMS
Revert a studio to its upstream name
Removes the override. Requires config:write.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
source_name (path) |
Yes | string |
— |
/v1/staff-hubspot
CMS
Mindbody staff → HubSpot agent links
Super Admins set these on the Rally staff profile so inbox replies send as that HubSpot user. Each row is one (site_id, staff_id). Requires contact:read.
/v1/staff-hubspot
CMS
Connect one staff member to a HubSpot owner
Body `{ site_id, staff_id, hubspot_actor_id, hubspot_owner_id?, hubspot_user_id?, email?, display_name? }`. `hubspot_actor_id` is `A-<userId>`. Requires contact:read.
/v1/staff-hubspot
CMS
Disconnect a staff member from HubSpot
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
site_id (query) |
Yes | string |
— |
staff_id (query) |
Yes | string |
— |
/v1/staff-hubspot/owners
CMS
HubSpot owners who can send inbox replies
Live `/crm/v3/owners` list, owners without a portal userId omitted. `actor_id` is `A-<userId>`. Requires contact:read.
CMS · contacts 7
/v1/contacts
CMS
Find contacts
Free-text `q` dispatches on shape: UUID → contact_id, all digits → MBO client id, contains `@` → email, formatted phone → last-10 digits of `phone`, 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, formatted phone, 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 |
— |
/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). |
/v1/contacts/{contact_id}
CMS
Edit a contact's name, email, or phone
Writes `funnel_enriched.contact` — first-party identity, not a cron resync. Genuinely partial. HubSpot is patched when a `hubspot_contact` identity exists so CRM lists match. Accepts `contact:write` or `contact:read` (same exception as conversation send). Each row carries `source: enriched`.
| 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). |
/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). |
/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 |
— |
/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. |
/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 |
— |
kinds (query) |
No | string |
Comma-separated activity kinds to load. Skips the other source queries. When a low `limit` would otherwise be all email events, the clip reserves page_view slots if any exist. |
CMS · visits 5
/v1/visits
CMS
Raw identified page views for one path
The rows behind a viewer list, newest first — the "show me the actual data" pane. When a count looks wrong, this is what it is made of. **Identified views only.** Every row carries a `client_id`; anonymous traffic is excluded outright. See `/v1/visits/counts`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
url (query) |
Yes | string |
A page PATH, e.g. /retreats/costa-rica-may-2024 |
limit (query) |
No | integer |
— |
source (query) |
No | string |
blended, all, hubspot, tracker, hubspot_backfill, hubspot_import |
since (query) |
No | string (date-time) |
— |
until (query) |
No | string (date-time) |
— |
/v1/visits/counts
CMS
Identified views per page, under a path prefix
One row per page under `path_prefix`: `url`, `views`, `distinct_students`, `first_seen`, `last_seen`. Feeds the Publisher's VIEWS column and the `/cms/post-visits` console page. **Identified views only, deliberately.** Every row behind these numbers carries a `client_id`, so `views` is NOT a page's total pageview count and will read low against GA or the tracker's own totals. The Portal answers this same question from `cms_clients_posts_visits`, every row of which carries an `hs_id` — so identified-only is what makes these numbers comparable to the ones staff already know, which is what lets that table be retired. `distinct_students` is usually the number that matters; `views` divided by it is repeat-viewing, not reach. Matched on `page_path`, not `page_url` — a path is what `funnel_content.post.public_url` holds, and grouping on the full URL would split one page's count across variants of it. A page under the prefix with no identified views is absent rather than zero: the caller knows which posts it asked about, and inventing rows would make an empty result indistinguishable from a prefix that matched nothing. No `limit` — a prefix bounds this to one row per post. Two forms, and they are not interchangeable. `urls=` is a CSV of exact paths — what a listing uses, because it knows which posts are on screen — and it has always been the straightforward one: `= ANY` is equality, equality is leakproof, so the planner pushes it below the row-security filter and uses `db/069`. `path_prefix=` is the exploratory form, and it took three migrations to make work. It needs `db/070`'s `text_pattern_ops` index, because a btree in a non-C collation cannot answer `LIKE 'x%'` at all; it needs `db/071`'s `SECURITY DEFINER` function, because `LIKE` is *not* leakproof and under RLS the planner will not push it below the row-security filter to reach that index; and it needs `db/073`'s dynamic SQL, because a definer function is never inlined, so a parameterised pattern is not a plan-time constant and the planner cannot derive the prefix bounds the index scan ranges on — 19.4s against 30ms for the same body with a literal. Pass one or the other, never both.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
urls (query) |
No | string |
CSV of exact paths, max 200. Mutually exclusive with path_prefix |
path_prefix (query) |
No | string |
Must begin with "/". Mutually exclusive with urls |
group_by (query) |
No | string |
url |
source (query) |
No | string |
blended, all, hubspot, tracker, hubspot_backfill, hubspot_import |
since (query) |
No | string (date-time) |
— |
until (query) |
No | string (date-time) |
— |
/v1/visits/viewers
CMS
The identified students who viewed one page
The people behind one row of `/v1/visits/counts`, most views first. Names come from the contact spine through `contact_identity`, joined on **(site_id, client_id)** and never `client_id` alone — Mindbody client ids are per-site sequential and collide across sites, so joining on the id would put one studio's student on another studio's event page. A `client_id` with no contact row still returns, with null names: it is a real view by a real person the spine has not resolved yet, and dropping it would make this list disagree with the count that opened it.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
url (query) |
Yes | string |
A page PATH |
limit (query) |
No | integer |
— |
source (query) |
No | string |
blended, all, hubspot, tracker, hubspot_backfill, hubspot_import |
since (query) |
No | string (date-time) |
— |
until (query) |
No | string (date-time) |
— |
/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.
/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 25
/v1/conversations
CMS
Inbox threads (newest first)
Inbox rows from two sources. HubSpot Conversations threads (chat, email, SMS, etc.) carry `source=hubspot_conversations`. Missed inbound CloudTalk calls and voicemails carry `source=cloudtalk` and a stable `ct:{call_id}` — HubSpot does not create channel-1008 threads for these. CloudTalk items qualify as incoming AND (is_missed OR is_voicemail) on or after 2026-08-09; answered live calls are not included. Use `channel_id` / `channel_ids` for multi-select. Full-text `q` searches subject and message bodies (and caller / studio line on calls). Each HubSpot row includes `contact_name` (CRM first+last only), `contact_email`, `contact_phone`, `contact_sender_name`, and `to_email` (first inbound email recipient — the studio mailbox). Call rows use CloudTalk's own `contact_name` and `public_external` — they are not joined onto a Funnel contact by phone. A `ct:` row also carries `has_recording`, `has_transcript`, and `transcript_source`. Voicemails and missed-with-recording (`talking_time=0`) are transcribed automatically by the recurring `cloudtalk-calls` job (Deepgram Nova-3 over the WAV). When that text exists, `transcript_source` is `deepgram` — never CloudTalk; their CI 404s on talking_time=0. Answered-call CloudTalk transcripts are not overwritten. `has_transcript=false` means the job has not landed text yet, or there is no recording. Read the text from `GET /v1/cloudtalk/calls/{call_id}/transcript`. Default list matches HubSpot's inbox: `spam=true` and `archived=true` (trash) are excluded unless those params are passed as `true`. Calls have no trash. **Reaching one person's calls.** A call row has no `associated_contact_id` and no `contact_email`, so `contact_id=` and an email `q=` both match HubSpot threads only. Pass `phone=` to scope call rows to a number; combined with `contact_id=` or an email `q=`, the HubSpot side filters by contact and the call side by phone, and the two are merged.
| 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 |
phone (query) |
No | string |
Scope CALL rows to this number (last 10 digits). A call thread carries no contact id or email, so this is the only way to reach one person's missed calls -- and the only thing that lets calls through alongside contact_id or an email q. |
q (query) |
No | string |
Name, email, subject, or message snippet. A full email resolves via crm_object.email_lower then associated_contact_id. Other text resolves via search_contacts_by_name (HubSpot id) and search_conversation_thread_ids (075 — name / subject / body / sender). Not a message-document ILIKE. |
spam (query) |
No | boolean |
true = spam folder only; omit/false = exclude spam (inbox default) |
archived (query) |
No | boolean |
true = trash only; omit/false = exclude archived (inbox default) |
counts (query) |
No | boolean |
false skips channel_counts (a GROUP BY over every visible thread, ~290ms). total_count is unaffected. Pass false when the caller does not render the channel inventory. |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/conversations/stats
CMS
Inbox volume by local day over a date range
Pre-aggregated counts for a Reports page. The list endpoint pages the newest threads, so 400 rows cover roughly three days and a 30-day report built from it is wrong; this returns every day in the window. Same visibility as the inbox list — real messages only, spam and trash excluded, CloudTalk rows limited to qualifying missed calls and voicemails. One row per `(day, source, channel_id, status, assigned_to, agent_name, call_status, location_name)` with integer `threads`, `ai_drafted` (threads with at least one non-escalated AI draft), `ai_sent` (threads where a draft was sent or sent edited) and `replied` (threads with at least one outgoing message from a person, an `A-` actor; always 0 on call rows). Threads from a sender remembered in `funnel_enriched.spam_sender` are excluded even when `spam_local` was never backfilled onto them. HubSpot threads bucket on `created_at` — when the conversation started, not its latest message — so a late reply does not move a thread between days; CloudTalk rows bucket on the call's `started_at`. Days are in `tz` (default `America/Chicago`). `assigned_to` is a HubSpot actor id (`A-<agent>`, `B-<bot>`); `actors` maps those ids to the most recent sender name seen on an outgoing message, looking back 180 days before `date_from`. `handlers` is who actually worked each conversation: one row per `(day, channel_id, handler)` where `handler` is the sender name on the thread's outgoing messages (the actor id when HubSpot sent no name) and `actor_id` tells a person (`A-`) from a bot (`B-`); a thread counts once per distinct handler who replied in it, so the rows can sum past `threads`. Both lookups are best-effort: on failure `actors` is `{}` / `handlers` is `[]` and the rows still return. Window is inclusive and at most 366 days.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
date_from (query) |
Yes | string (date) |
First local day, YYYY-MM-DD |
date_to (query) |
Yes | string (date) |
Last local day (inclusive), YYYY-MM-DD; at most 366 days after date_from |
tz (query) |
No | string |
IANA zone the days are bucketed in |
/v1/conversations/{thread_id}
CMS
One conversation thread
One inbox item. HubSpot threads carry Conversations metadata (status, channel, assigned agent). `ct:{call_id}` is a CloudTalk missed call or voicemail (`source=cloudtalk`) with recording / wait / studio-line fields. `has_transcript` is true once Deepgram (or, for answered calls only, CloudTalk CI) has text; `transcript_source` is then `deepgram` or `cloudtalk_intelligence`. Never treat a voicemail transcript as CloudTalk's — that text is Deepgram, landed by the same `cloudtalk-calls` job that syncs the CDR. Message bodies are the separate `/messages` sub-resource.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}
CMS
Open, close, or mark a thread as spam
`status` (`OPEN` or `CLOSED`) and/or `spam`. HubSpot threads proxy `status` to HubSpot (`PATCH /conversations/v3/conversations/threads/{id}`) and store `spam` locally (`spam_local`) — HubSpot's public PATCH rejects `{ "spam": true }` as an empty body (`ConversationsApiError.EMPTY_UPDATE_REQUEST_BODY`). CloudTalk `ct:{call_id}` items write both fields to `funnel_enriched.call_inbox` (CloudTalk has no per-call close or spam). Spam also tries `PUT /blacklist/add.json` against the caller number; that write is best-effort and the local hide still lands if CloudTalk is down. Body: `{ "status": "CLOSED" }`, `{ "spam": true }`, or both.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/intent
CMS
AI-classified intent for a thread, if one has been recorded
The drafting pipeline's classification of this thread (`funnel_enriched.conversation_intent`), written opportunistically only for threads that went through the proactive draft trigger. Most threads have no row — that is expected, not an error — so a missing row returns `200` with `data: null` rather than `404`. `intent_summary` is a short (~12 word) AI-generated gist of what the customer wants.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/messages
CMS
Messages in a thread (chronological)
Every message in the thread, oldest first, including staff COMMENTs (internal notes). `sender_name` is the HubSpot sender when present; internal notes usually omit it, so the row falls back to the owners export (`funnel_hubspot.asset` kind `owner`) via the A-* actor. System/bot events stay in the row set; the console hides those. An empty text_body usually means an attachment, not a blank message. When the thread starts on an inbound reply to a marketing email or campaign SMS (HubSpot does not copy that send onto the conversation), the first row is a synthesized OUTGOING with `source` `hubspot_email_campaign` or `hubspot_sms`. Pass `origin=false` to skip that lookup so the thread body can paint first.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
origin (query) |
No | boolean |
When false, skip the synthesized campaign-origin row. The inbox uses this for first paint, then refetches with the default. |
/v1/conversations/{thread_id}/messages
CMS
Reply on a live-chat, email, form, or SMS thread
Proxies a text reply to HubSpot Conversations. Live chat (channel 1000), email (1002), forms (1003, sent as email), and SMS (1009). Email/form copies the thread subject (prefixed Re:) and the inbound address as TO. `{ "type": "note" }` posts a HubSpot COMMENT the customer cannot see (any channel, including closed threads). HubSpot is the system of record; the landing zone picks the new row up from the response and again from the webhook. Body: `{ "text": "…", "type": "reply"|"note"|"forward", "to": "…", "attachments": [{ "filename", "content_type", "data" }] }`. `data` is base64. Text may be empty when at least one file is present or when `type` is `forward` (the quoted thread is the body). `{ "type": "forward", "to": "a@b.com" }` emails the thread to a new address on the same HubSpot conversation (email / form threads). Rally injects `sender_staff_id` / `sender_site_id` / `sender_email` from the logged-in session so a Super Admin staff→HubSpot link sends as that agent.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/messages/{message_id}/original-content
CMS
Full forwarded/replied email body from HubSpot
Proxies HubSpot GET /conversations/v3/conversations/threads/{threadId} /messages/{messageId}/original-content. Funnel's message list lifts text/richText, which HubSpot strips of quoted MIME — this returns the original so Rally can show the forwarded thread in-inbox. `message_id` is the Funnel UUID (HubSpot's message id). 404 if that message is not on the thread. Auth is the same `contact:read` as the other conversation GETs.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
message_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/messages/{message_id}/media
CMS
Stream Instagram or HubSpot FILE attachment bytes
Proxies a signed attachment URL from our stored message — Instagram `socialMetadata.mediaUrl` (lookaside.fbsbx.com) or a live-chat FILE on hubspotusercontent. Those URLs expire. On a miss this re-fetches the message from HubSpot for a fresh signature. The URL comes from our row, never from the query string.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
message_id (path) |
Yes | string |
— |
index (query) |
No | integer |
FILE attachment index. Omitted prefers Instagram media |
/v1/conversations/{thread_id}/draft
CMS
Generate an AI-suggested reply for a thread
Classifies the thread against the tenant's intent taxonomy, retrieves the most similar past human replies for that intent from the reply corpus, and drafts the next reply with GLM-5.3-Flash in the team's voice. The draft is logged as status=proposed and returned — nothing is sent to the customer; the console sends through POST /v1/conversations/{thread_id}/messages after a human approves. Never-automate intents, low classifier confidence, or a handoff signal (anger, human request, legal, injury) return `escalate: true` with no draft_text. Older proposed drafts on the thread become superseded. Idempotent per customer message: a draft already anchored to the current last inbound message is returned as-is (`cached: true`, HTTP 200) whatever its status — only a new customer message generates. Body: `{ "agent_first_name": "…" }` (optional, used for the sign-off).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/drafts
CMS
Draft history for a thread
Every AI draft for the thread, newest first, with its human outcome (proposed / sent / edited / discarded / superseded). The per-intent edit rate from these rows is the evidence that gates any future auto-send.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/conversations/{thread_id}/drafts/{draft_id}
CMS
Record what the human did with a draft
Sets the outcome on a proposed (or superseded) draft. `sent` copies draft_text into final_text; `edited` requires final_text — the diff against draft_text is the training signal; `discarded` records a rejection. Body: `{ "status": "sent"|"edited"|"discarded", "final_text": "…" }`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
draft_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/drafts/{draft_id}/approve
CMS
Approve a SupportBot plan
Body may carry `final_text` (the reply as edited in the console); it is sent instead of draft_text and the outcome records edited. Runs every next step on the draft (cancel membership first), logs each hop as an internal HubSpot note, and only then sends the drafted reply. A failed step stops the plan and does not send. Requires contact:read (same as sending a message).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
draft_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/hide-sender
CMS
Hide future mail from this sender (not spam)
Archives this thread and any other open threads from the same inbound identifier (email, phone, or actor id). Remembers the identifier so a later invoice or privacy notice is archived on ingest. Does not mark spam. Body: `{ "identifier": "…" }` optional; omitted, uses the latest inbound sender.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
/v1/conversations/{thread_id}/transcript-email
CMS
Email this conversation's transcript to the customer
Sends the conversation to the address in `{ "to": "…" }` (or the thread's CRM contact email). Prefers SendGrid; if SENDGRID_API_KEY is unset, emails through the studio's HubSpot inbox instead. Internal notes (HubSpot type COMMENT) are never included. `{ "to": "…" }` is required when the contact has no email (common for an unknown caller). A `ct:{call_id}` thread mails the call transcript and CloudTalk summary. Each call sends mail — not idempotent, and never cached. The item names `source` (`sendgrid` or `hubspot`) so a client can tell which hop actually accepted the send.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
thread_id (path) |
Yes | string |
— |
/v1/supportbot/config
CMS
SupportBot configuration for the tenant
Everything that defines the AI reply drafter, in one read: brand profile (voice, pricing notes, confidence floor), the membership price sheet, the intent taxonomy with its draft allowlist, the house rules, and the next-step capability catalog. Read-only — these are database rows the owner edits; a write surface comes with an admin UI. Requires config:read.
/v1/supportbot/intents/{intent_code}
CMS
Toggle an intent's draft allowlist flag
Body: `{ "draft_enabled": true|false, "answer_only"?: true|false }`. answer_only marks an informational intent whose drafts may plan no account action. Requires config:write.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
intent_code (path) |
Yes | string |
— |
/v1/supportbot/rules
CMS
Add a house rule
Body: `{ "rule_text": "…", "rule_scope": "global"|"intent:<code>"|"channel:<id>" }`. Requires config:write.
/v1/supportbot/rules/{rule_id}
CMS
Activate or deactivate a house rule
Body: `{ "is_active": true|false }`. Deactivate rather than delete — history survives. Requires config:write.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
rule_id (path) |
Yes | string |
— |
/v1/supportbot/plans/{plan_code}
CMS
Edit a membership plan on the price sheet
Body: any of plan_name, plan_price, plan_terms, renewal_note, plan_is_active. Requires config:write.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
plan_code (path) |
Yes | string |
— |
/v1/supportbot/brand
CMS
Edit drafting behavior
Body: any of draft_min_confidence (0-1), pricing_notes, voice_prompt. Requires config:write.
/v1/supportbot/steps/{step_code}
CMS
Toggle a next-step type
Body: `{ "step_is_active": true|false }`. Requires config:write.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
step_code (path) |
Yes | string |
— |
/v1/stream
CMS
Live change stream (Server-Sent Events)
A long-lived `text/event-stream` that emits an event whenever a conversation thread or message changes for the authenticated tenant, within about a second of HubSpot reporting it, or of a CloudTalk Workflow landing a missed call / voicemail (`thread_id` is `ct:{call_id}` when Deepgram writes `transcript_text`). Events carry ids and labels only — re-read `/v1/conversations/{thread_id}/messages` for the body. Named events: `ready` (sent once on connect), `conversation_thread`, `conversation_message`, plus `: keepalive` comment frames every 25s. Not an envelope endpoint: no pagination, no ETag, no `source` field — each event names the table it came from. Reconnect with backoff; a deploy closes every open stream.
/webhooks/hubspot/conversations
CMS
HubSpot conversation delivery (not for API clients)
Inbound only, and the one path here that takes no API key: HubSpot authenticates by signing the delivery with the private app's client secret (`X-HubSpot-Signature-v3` over `POST + url + raw body + X-HubSpot-Request-Timestamp`, five-minute window). Subscriptions are configured in the private app's Webhooks tab, which is UI-only. Answers 200 before fetching and landing the named threads, because HubSpot batches up to 100 events per POST and counts a slow response as a failure. Work lost to a crash is reconciled by the conversations cron. Unconfigured deployments 404 this path rather than accept unsigned traffic.
/webhooks/cloudtalk
CMS
CloudTalk Workflow delivery (not for API clients)
Inbound only, and takes no API key. CloudTalk has no subscribe-via-API webhook — a dashboard Workflow (Call Ended, optionally Recording Uploaded) POSTs here with `X-CloudTalk-Webhook-Secret` (or `Authorization: Bearer`) matching `CLOUDTALK_WEBHOOK_SECRET`. The body must name a numeric call id (`id`, `call_id`, or `Cdr.id`). We re-fetch the CDR from CloudTalk, upsert `funnel_cloudtalk.call`, and Deepgram a voicemail when a recording is already there (`transcript_source=deepgram`). Answers 200 before that work. The hourly `cloudtalk-calls` job is the backstop. Unconfigured deployments 404 this path.
CMS · sms 4
/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 |
— |
/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 |
— |
/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 |
— |
/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
/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 or HubSpot list id |
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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 |
— |
/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 · content 24
/v1/content/posts
CMS
Authored event, retreat and training content
The Publisher's posts, migrated into `funnel_content` (db/061-065) and written by Rally's editor after cutover. Scope: `content:read`. Returns ONLY authored content. Nothing Mindbody owns is here — no live capacity, no booked count, no staff bio, no location detail. Compose those, and compose on **`mbo_schedule_id`**: - `mbo_schedule_id` is the OCCURRENCE — unique per post, and the id Datastream knows as `class_schedule_id` (equal to `event_id` on `/v1/events`). This is the join. - `mbo_enrollment_id` is the PROGRAM — 664 distinct values across 2,396 posts, one enrollment covering up to 103 of them. Good for "every occurrence of this", useless for identifying one. `capacity_authored` and `spot_status` are what a human typed at publish time, never live figures. So is `booked_authored` (db/090): a current attendance somebody typed over Mindbody's, `null` when nobody has. Places left is `capacity_authored` minus it when set, and minus Datastream's `total_booked` on `mbo_schedule_id` when it is not. It is writable like any other scalar, a whole number of zero or more, and `null` hands the count back to Mindbody. Soft-deleted posts are EXCLUDED by default — 2,845 of the 5,384 migrated posts are retired, so a caller that forgot the filter would show twice the content it should.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_type (query) |
No | string |
event, retreat, training |
status (query) |
No | string |
draft, submitted, waiting, published, cancelled |
site_id (query) |
No | string |
MBO numeric site id as the Publisher stored it |
mbo_enrollment_id (query) |
No | integer |
Every post for one program — the many-occurrence case |
q (query) |
No | string |
Matches title, hero_title or slug |
featured (query) |
No | string |
true, false |
is_free (query) |
No | string |
true, false |
starts_after (query) |
No | string (date) |
— |
starts_before (query) |
No | string (date) |
— |
include_deleted (query) |
No | string |
true, false |
order (query) |
No | string |
start_date, -start_date |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/content/posts
CMS
Create a post
The first write into `funnel_content`. Until this, everything in there arrived through `scripts/load-publisher-content.ts`. Scope: `content:write`. `post_type` and a non-blank `title` are the only required fields. Everything else is filled in over the life of a draft, and refusing a half-filled one would mean an author loses what they typed the moment they step away — which is the whole point of a draft. **`status` cannot be `published`.** Publishing is not a status change: it creates the Mindbody enrollment and writes the id back, which is `POST /v1/content/posts/{post_id}/publish`. A post marked published by this path would read as live in every list while nothing had been published anywhere. `draft` (the default), `waiting` and `submitted` are the three an editor can legitimately reach - `waiting` is the review queue Rally submits into, `submitted` the one Publisher V3 sets. **Omitted, `null` and `""` are three different things.** Omitting a field leaves it alone, `null` clears the column, and an empty string is stored as an empty string — the editor sends `""` for a field an author cleared, and folding that to NULL would make the read path unable to tell them apart. `prices` and `teachers` are written as given, in array order, which becomes their `position`. `images` is keyed by gallery kind and ordered within each one; see the field description. The slug is derived from the title and suffixed if taken — `sound-bath`, `sound-bath-2`. It comes back on the response, so a caller never has to re-read to learn what it got.
/v1/content/posts/{post_id}/publish
CMS
Publish a post - create its Mindbody enrollment
Scope: `content:write`. No request body. **This is the one write here that cannot be undone.** It creates a real, bookable enrollment in Mindbody through Datastream (`POST /v1/enrollment-definitions`) and then records the `class_schedule_id` it returns on the post, as `mbo_schedule_id`, alongside `status: published`. Every precondition is checked BEFORE Mindbody is called, and a 400 names the single thing to fix - a missing MBO enrollment ID, no location, no day ticked under "repeats on", nothing that can pay for a place. Nothing has happened when one of those is returned. A post that is already published is refused: publishing twice creates a SECOND enrollment, and nothing can retract the first. The AI `intro` and `short_description` are rewritten after this call answers, not during it. grok-4 takes up to 30 seconds and the author is waiting on Mindbody, so the work is detached exactly as the Portal detaches it with `fastcgi_finish_request()`. A 200 therefore does NOT mean the new copy is in the row - re-read the post a few seconds later. If the model is unreachable the two fields simply keep their previous values; it can never fail a post that is already live. An event's `public_url` is written after this call answers too: the studio's events page, the start date, and the website's slug for the Mindbody course, e.g. `/locations/austin/westgate/events/2026-10-02/authentic-relating` - the address the Portal gave every event it published. It is written only when the post has none, and never changed afterwards, because page views are counted on it. Retreats and trainings get none yet. The published-notification email goes out once both are done, so it quotes the new copy and links to that page. Eventbrite is its own call, below. Two more steps run after this call answers, and the email does not wait for them. Datastream is asked to re-pull the new enrollment and every date of it into its copy (its `POST /v1/sync/enrollment`), so `/v1/events` and `/v1/schedule` show it within seconds rather than at its next sync. A price saved without a name gets its Mindbody service's name; a name somebody typed is never replaced. A save of a published post repeats both - the Datastream refresh coalesced per post - because that save has just changed Mindbody. Last, once the address, the copy and the price names are written, the Flow website is told (its `rally-events-refresh.php`): it drops its saved copies of the event and has Cloudflare forget the event's rally-events page and the rally-events list, so they show the post as it now is. A save of a published post does the same. Needs `WEBSITE_REFRESH_KEY`; without it nothing is sent.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/ora/status
CMS
Whether this deployment can reach Ora
Scope: `content:read`. For Rally's Settings. `configured` says whether `ORA_API_KEY` is set, and `mode` which of Ora's two modes it acts in - `test` (Ora's sandbox, free tickets only) or `live`. `reachable` is one call to Ora's `/ping`: false with a `detail` for a wrong or wrong-mode key (401) or one without scopes (403), and null when there is no key to try. `ping` is Ora's own answer as it came. Never cached, and never the key itself.
/v1/content/posts/{post_id}/ora
CMS
Create the Ora event for a published post, and publish it
Scope: `content:write`. No request body. Sends the post to Ora's `POST /events` with the post's own id as the `external_id`. Ora creates the event as a DRAFT and dedupes on that id, so a second call updates the same event (`created: false`) instead of making another. Safe to retry. Events only: any other post type answers `reason: not_wanted`. Once its picture, place and tickets are on it, the event is published (`POST /events/{ora id}/publish`) - ONCE: an event Ora already calls published is left alone, since publishing again re-stamps its `published_at`. When Ora will not publish it (one of its gates: a start in the past, no visible ticket tier, the place, a linked venue without a confirmed booking), or keeps it a draft because the organizer account awaits approval, `event_status` stays `draft`, the reason comes back in `warnings`, and the next save tries again. Sent: title, summary (the post's `short_description`, else its tagline, else the start of its description, cut at a word to Ora's 300 characters), the description as Markdown with the events-page footer, start and end as UTC instants with `timezone: America/Chicago`, `currency: USD` and `template: wellness`. The post's hero image follows in two more calls: Ora's `POST /media` fetches it from its public address into Ora's `event-flyers` folder, then `PATCH /events/{ora id}` sets it as `image_url` and `hero_image_url`. Only when the hero changed - Ora's media store keeps a new copy on every upload - which `ora_synced.image` records (db/093). A post that lost its hero has the event's image cleared. A picture that did not land comes back in `warnings`; the event is unaffected and the next save tries it again. The location goes IN THE CREATE of a new event, and NAMES the place rather than linking a venue. Measured by `ora:probe` (2026-10-03): linking one of our venues answers HTTP 500 every way; a named place in the create of a new event is kept; changing it afterwards (PATCH, or the create re-sent) answers 200 and changes nothing. Venues are setup data and serve as the address book: every studio's is made once by `scripts/ora-venues.ts` (`ops.yml` job `ora:venues`), keyed `flow-studio:<site>:<location>`, and a saved custom location's when the location is saved (`POST /custom-locations`), keyed `flow-custom:<id>`. So a new studio event - or one at a custom location with a venue (`ora_venue_id`) - has its venue read (`GET /venues`) and is created with `location: {mode: Venue, venue_name, venue_address, venue_city}`; a custom location without one - its map did not resolve to an address - with just `venue_name`. Online gets no location. A place that changes after the event exists is said once in `warnings` - change it on Ora - and recorded in `ora_synced.location`. A send never creates a venue or geocodes. A venue Ora does not have - a studio before the setup job has run - leaves the event without a place, and is said so in `warnings`. The tickets are made ONCE, the first time the event is sent - at publish, or on the save that ticks the box on a published post - from the prices the event has then; later edits do not change them (`ora_synced.tickets` records that they were made). They are the Eventbrite listing's tickets: every price on the event becomes one, whatever it is called, named after its Mindbody service, priced in cents (`tier_type: free` at $0), with the post's capacity as its `quantity` and no sale end date. One difference: a free post gets one $0 ticket, `Free` (keyed `<post id>:free`) - the Eventbrite listing sells none, but Ora will not publish an event without a ticket. One `POST /events/{ora id}/ticket-types` each, keyed `<post id>:<mbo service id>` (or `<post id>:p<position>` without a service). An event with no ticket to make yet is looked at again on its next save. A ticket that did not land comes back in `warnings`, and the next save sends them all again - the key makes that an update of any that did land, not a second copy. What Ora answered is recorded on the post as `ora_event_id`, `ora_event_slug` and `ora_event_status` (db/092). **It answers 200 even when nothing was created**, like the Eventbrite listing: the post is live in Mindbody whatever Ora says. When nothing was sent or Ora said no, `reason` is one of `gone`, `not_published`, `not_wanted`, `not_configured`, `no_dates`, `refused` (with Ora's own `detail`, field list included) or `unreachable`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}/ora/unpublish
CMS
Untick Publish to Ora and take the event off Ora
Scope: `content:write`. No request body. What Rally's editor calls after the author confirms unticking "Publish to Ora" on a published post - the Ora half of `/eventbrite/unpublish`. Ora's `POST /events/{id}/unpublish` takes the event back to a draft, keeping its tickets, orders and `published_at`. Ticking the box again (`/ora/publish`) puts it live again. ALL OR NOTHING: `publish_to_ora` is saved false only once Ora has said yes. `action` is `unpublished`, `no_event` (no Ora event; only the box was saved), `failed` (Ora refused - a cancelled, completed or archived event is a 409 - or did not answer; nothing was saved, the box stays ticked), `not_configured` (nothing saved) or `gone`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}/ora/publish
CMS
Tick Publish to Ora on a live event and send it now
Scope: `content:write`. No request body. The other half of `/ora/unpublish`. The box is saved, then the event is sent as `POST /posts/{post_id}/ora` sends it: made if there is none, updated if there is, and published if Ora has it as a draft - which puts an unpublished event live again (Ora allows that; Eventbrite does not). Answers as `/ora` does. Nothing is saved for a post that is not a live event: `reason: not_published`, or `not_wanted` with "Only events go to Ora." Where Ora cannot be reached the box stays ticked and the next save sends the event again.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}/eventbrite
CMS
Create the Eventbrite listing for a published post
Scope: `content:write`. No request body. A SEPARATE CALL FROM PUBLISH, matching the Portal, which runs publish as three steps and only makes this one when the author ticked "Publish to Eventbrite". Folding it in would make the author wait on eight Eventbrite round trips - image uploads included - before hearing that Mindbody accepted the post. **It answers 200 even when no listing was created.** The post is already live in Mindbody and nothing here can undo that, so this is never what fails a publish. Branch on `created`; when it is false, `reason` is one of `gone`, `not_published`, `not_wanted`, `already_listed`, `not_configured`, `no_dates` or `refused`. **`warnings` means the listing exists but part of it did not.** Once the event is created every remaining step - tickets, the hero image, the gallery, the description - is best effort, and the id is recorded whatever happens to them. An Eventbrite event this service has no record of is the one outcome with no way back. Refuses a post that already carries an `eventbrite_id`: a second listing would leave two events and one recorded id, with no way to tell which one people are buying from. Calls for the same post queue behind each other and behind the save sync, and the id is recorded the moment the event exists, only if the post has none. A request that loses that race deletes its own draft and answers `already_listed`. A post with `is_private` set is created UNLISTED on Eventbrite - reachable by its link, kept out of Eventbrite's search - the way the post is kept off the website.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}/eventbrite/unpublish
CMS
Take a post's Eventbrite listing down and untick Publish to Eventbrite
Scope: `content:write`. No request body. What Rally's editor calls after the author confirms unticking "Publish to Eventbrite" on a published post, so they hear whether the listing came down rather than trusting the detached save sync. UNPUBLISH, NOT DELETE: the event, its orders and its attendees survive. It can only be published again in Eventbrite itself - see `/eventbrite/publish` below for why. **All or nothing.** `publish_to_eventbrite` is saved false only once Eventbrite has said yes. Branch on `action`: * `unpublished` - taken off Eventbrite, box saved unticked. * `already_off` - the listing was not live; only the box was saved. * `no_listing` - the post has no listing; only the box was saved. * `failed` - Eventbrite refused; nothing changed. `detail` says why. * `not_configured` - no Eventbrite keys here; nothing changed. * `gone` - the post went away meanwhile. Answers 200 for all of them, like the listing endpoint.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}/eventbrite/publish
CMS
Tick Publish to Eventbrite on a published post, creating its listing if it has none
Scope: `content:write`. No request body. The other half of `/eventbrite/unpublish`: what Rally's editor calls when the author ticks "Publish to Eventbrite" on a published post. A post with no listing gets one created, which is the full listing sequence and can take a minute. **A hidden listing is not published again.** Eventbrite refuses to publish an event that has a venue without tax settings (`EVENT_TAX_SETTINGS_MISSING`), and has no public API to set them. A first listing never meets that because it is published before its venue is attached. So a listing that was unpublished answers `republish_unavailable`, with a `manage_url` to publish it in Eventbrite. Eventbrite is asked first: a listing already published again there is `already_live`, and the box is saved. **All or nothing.** `publish_to_eventbrite` is saved true only for a listing that is live. Edits made while the box was off are sent by a sync queued straight after, detached. Branch on `action`: * `created` - a new listing was made, box saved ticked. `warnings` lists parts of it that did not land. * `already_live` - the listing is live; only the box was saved. * `republish_unavailable` - the listing is hidden; nothing changed. `detail` and `manage_url` say to publish it in Eventbrite. * `failed` - Eventbrite refused or could not be asked; nothing changed. `detail` says why. * `not_published` - the post is not live; nothing changed. * `not_configured` - no Eventbrite keys here; nothing changed. * `gone` - the post went away meanwhile. Answers 200 for all of them, like the listing endpoint.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}/clone
CMS
Copy a post into a new draft
Scope: `content:write`. The Portal's Clone link, which staff use to run the same retreat or workshop again without retyping a description, six prices and four teachers. Body is optional and takes two fields. `title` is used verbatim; omit it for "<original> Copy", the Portal's wording. `updated_by` is the staff id doing this, recorded as both author and last editor of the copy. Any other field is a 400. **The copy is a draft and nothing else has happened** - nothing is scheduled in Mindbody, no Eventbrite listing, no page. It carries the original's content, its prices, teachers, images, schedule, rooms and blocks, and none of its identities: `publisher_post_id`, `public_url`, `eventbrite_id`, `mbo_schedule_id`, `featured` and the slug history all stay with the original. `mbo_enrollment_id` IS carried, unlike the Portal's clone. It is the class DESCRIPTION - a reusable definition dozens of runs are scheduled from - and cloning an event to run it again means the same course again. `mbo_schedule_id`, the run itself, is what must not come across. **Dates are reset to today in Texas**, which is what the Portal does. A copy carrying the original's dates is either in the past, where Mindbody refuses it, or a plausible date that makes the copy look ready - and publishing it would put a second identical run on the same day. Times ARE carried, which the Portal does not do. Repeatable. The Portal refuses a second clone of one post; here the slug simply counts, so the next copy is `-copy-2`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}/cancel
CMS
Call off a published post
Scope: `content:write`. No request body. A port of the Portal's Cancel link, which only ever appears on a published post - so anything else is a 400, and there is no un-cancel. **The post** goes `is_private = true` and `status = cancelled`. The Portal has no cancelled status and expresses the whole thing as `private = 1`, which is what takes a post off the website; both are written here so the flag still means what it means and this Publisher's own list can say Cancelled. **Eventbrite** is cancelled - left up and marked Cancelled, which is what a ticket holder going back to look for it needs to find, rather than deleted or unpublished. `action` says what happened: `cancelled`, or `sales_closed` when Eventbrite refuses because money is against it, in which case every ticket tier is pinned to what it has already sold so nobody else can buy, and `manage_url` is where a person refunds and finishes. Also `none`, `failed`, `not_configured`. **Ora** is cancelled the same way, in its own call whatever Eventbrite said: `POST /events/{ora id}/cancel` keeps the event on Ora, marked Cancelled, with its tickets and orders. `ora.action` is `cancelled` (`ora_event_status` becomes `cancelled`), `none` (no Ora event), `failed` (Ora refused - a completed or archived event is a 409 - or did not answer; `detail` says which, and the event is as it was) or `not_configured`. `event_url` is the event's page on Ora. **Mindbody is NOT touched.** The enrollment stays live and bookable. The response carries `mindbody.class_schedule_id`, a back-office `admin_url` and a note saying so, the same way delete does. The Portal behaves identically while its own dialog claims otherwise. As on delete, Datastream is then asked to refresh its copy of the enrollment, which only changes anything once Mindbody no longer has it, and the Flow website is told, so the event's page stops selling tickets now rather than when Cloudflare's copy expires.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}
CMS
One post, with its blocks, schedule, prices, teachers and images
`post_id` accepts three forms, dispatched on shape, because three systems address the same post three different ways: - a uuid — funnel's own id, what Rally holds - an integer — `publisher_post_id`, how the Portal addresses every post - anything else — the slug, how the public site addresses it Returns the post whatever its state; `deleted_at` is in the response so a caller that must not render retired content checks it, the same way it already has to check `status`. `legacy_source` is never returned — it is the migration archive column (db/062) and is scheduled to be dropped.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_id (path) |
Yes | string |
— |
/v1/content/posts/{post_id}
CMS
Update a post
A PARTIAL update. Scope: `content:write`. A field that is not in the body is not touched, which is what lets the editor send only what changed and what makes an autosave possible later. `null` clears a column; `""` stores an empty string. Those are three different instructions, not three spellings of one. `prices` and `teachers` REPLACE their whole set when present, because the editor holds them as a list and there is no stable id on a row an author can reorder or delete. Omit them to leave them alone; send `[]` to mean the author removed them all. `images` replaces PER KIND rather than wholesale, because `post_image` holds four galleries and no client renders all four — taking the table wholesale would mean an editor showing three of them silently deleting the fourth on every save. `post_id` accepts the same three forms the GET does — uuid, the integer `publisher_post_id`, or the current slug — so a caller that read a post can write it back with what it already holds. **An update clears `legacy_source`.** db/062 stamps that on every post the loader wrote, and `load-publisher-content.ts` refuses to TRUNCATE when it finds a post without one. A post the loader wrote and an author then edits here would otherwise keep its stamp, and the next re-export would quietly overwrite the edit. Clearing it turns a silent overwrite into the loader stopping and saying so. Changing the title re-slugs the post. `post_slug_history` (db/061) exists for keeping the old one, but recording it is the publish path's job — an unpublished draft has no link anybody holds, so it simply takes the new slug. **`status` cannot be `published`** — see the create above. To edit a post that already is, send NO `status` at all: an update that names none leaves the column untouched. Sending `waiting` instead pulls a live post back into the review queue while its Mindbody enrollment carries on existing. **Editing a published post writes to Mindbody, and can be refused.** The enrollment is updated FIRST, through `PATCH /v1/enrollment-definitions/{class_schedule_id}` on Datastream, carrying the location, room, staff, pay rate, booking status, dates, times and days. If Mindbody will not take it - the room is already reserved at the new time, the staff session expired - this answers 400 and **nothing is saved**, so the page and the thing people book cannot end up disagreeing. Posts with no `mbo_schedule_id` skip it silently: they are published here but were never created in Mindbody. Two fields never reach Mindbody on an edit, because its update has no place for them - `class_description_id` (documented as "overridden if sent") and pricing. Pricing is settable only when the enrollment is created; afterwards it changes in the back office. **Editing a published post also updates its Eventbrite listing.** Detached, after this answers, and it branches three ways: a post that has a listing and still wants one is UPDATED - name, times, venue, organizer, capacity, the quantity on its existing tickets, the hero and the description; one that has a listing and no longer wants it (`publish_to_eventbrite` turned off) is UNPUBLISHED, not deleted, so its orders and attendees survive; one that wants a listing and has none gets it created. A listing that is hidden is never published again from here (Eventbrite asks for tax settings it has no API for), only kept up to date. Only the parts of the listing whose inputs changed since they were last sent are sent (`eventbrite_synced`, db/091), so a save that moves the title does not upload the gallery again, and saves that arrive while a sync is waiting are folded into it. Unlike the Mindbody sync above, this can never fail the save. The enrollment is what people book; the listing is a copy of the page, so a stale one is worth less than a lost edit. Outcomes go to the log as `eventbrite_sync`, never to this response. **The Ora event follows the same way**, when `publish_to_ora` is set: the post is sent to Ora's create again, which is an update when the event exists (see `POST /posts/{id}/ora`). Logged as `ora_sync`. Ticket PRICES are never changed, only quantities. An Eventbrite ticket's price comes from a Mindbody service, and that picker is locked once a post is published. **Editing a published post can rewrite its AI copy.** If the `title`, `description_html`, `start_date` or `end_date` actually changes value, `intro` and `short_description` are regenerated after this call answers — same detached path as publish, same caveat that a 200 does not mean they have landed. Any other edit leaves them alone, so moving a price or a room does not spend a model call.
/v1/content/posts/{post_id}
CMS
Soft-delete a post
Sets `deleted_at` and leaves everything else — the row, its prices, its teachers, its slug history — exactly where it was. Scope: `content:write`. Soft rather than hard, for the reason db/061 gave the column in the first place: the Portal's own delete is `is_removed = 1`, 2,284 of its 4,737 events are in that state, and they are still referenced. A post is also what a published page, an Eventbrite event and a Mindbody enrollment all point at, so removing the row would strand three other systems. **It takes the Eventbrite listing down too - unless somebody bought a ticket.** Orders are counted first: none and the event is deleted and `eventbrite_id` cleared; any and the listing is LEFT ALONE. The response's `eventbrite` object says which, as `action`: `none`, `deleted`, `kept`, `unknown` (Eventbrite would not say, so it was left alone), `failed` or `not_configured`, with an `orders` count when one was established. The Portal deletes the listing blind and ignores the answer, so an event with orders - which Eventbrite refuses to delete - silently stays listed there. **It takes the Ora event off Ora too - UNPUBLISHED, not cancelled**, a deleted post being usually a mistake or a duplicate rather than an event called off - and, like the listing, leaves it as it is when tickets are sold (`GET /events/{id}/ticket-types`, `quantity_sold`). The response's `ora` object says which, as `action`: `none`, `unpublished`, `kept` (with `sold`), `unknown` (Ora would not say, so it was left as it is), `failed` (Ora refused - a cancelled, completed or archived event is a 409 - or did not answer) or `not_configured`, with `event_url` and Ora's `detail`. **Mindbody is never touched.** A published post's enrollment stays live and bookable, because it can have people on it who have paid and no tidy-up should destroy that. The response carries a `mindbody` object with the `class_schedule_id` and a deep link into the back office, so whoever deleted the post can finish the job. The Portal emails info@ instead, which reaches somebody who may not be them. After it answers, Datastream is asked to refresh its copy of that enrollment (`POST /v1/sync/enrollment`). Usually nothing changes, since Mindbody still has it; when it was deleted in Mindbody first, that refresh takes its upcoming dates off `/v1/schedule`. For a post that was ever published, the Flow website is told too (`rally-events-refresh.php`), so the event's page and its card on the rally-events list go now rather than when Cloudflare's copy expires. The LISTS filter `deleted_at IS NULL` by default, so a deleted post leaves them and leaves the editor. A direct `GET /v1/content/posts/{post_id}` still returns it, deliberately and by its own note — a lookup by id is a question about one specific post, and 404ing a retired one would remove any way to inspect it. `deleted_at` is on the response, so a caller that must not render retired content checks it, the same way it already checks `status`. Recovering one means clearing the column with owner credentials — undelete is not an endpoint, because nobody has asked for that workflow and guessing at it would be inventing one. Deleting an already-deleted post answers 404, the same as an id that never existed. Like an update, this clears `legacy_source`: a post the loader wrote and somebody then deleted here is no longer a pure copy of the Portal's, and the next re-export must stop rather than quietly bring it back.
/v1/content/faq-templates
CMS
The FAQ question bank
What the studio offers an author writing any post of a type — 150 questions across retreats and teacher trainings, each with its answer. Scope: `content:read`. The Publisher puts these in a native datalist on its FAQ question box and fills the answer in when one is picked (`getFaqsAnswer()`). This is the same list, so both editors suggest the same things during cutover. **Not `post_block kind=faq`**, which is what one post actually says. `db/062` drew that line and the content loader encoded it as `FAQ_MODE=skip`: copying the type-level set onto 5,384 posts would invent content nobody wrote there. `db/083` is where the type-level set lives. `post_type` is optional — omitted returns the whole bank, which is what a client caching it once for an editor session wants. Ordered by question, so the list does not reshuffle between two page loads. Loaded by `scripts/load-publisher-faqs.ts` (`ops.yml -f action=sync -f sync_job=publisher:faqs`), which upserts and prunes only the rows that came from the Portal.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
post_type (query) |
No | string |
event, retreat, training |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/content/custom-locations
CMS
Reusable venues for events held outside a studio
The Publisher's "Saved locations" list, ported from `flow_cms_custom_locations` (db/076). Scope: `content:read`. This is the LIST an editor picks from, not the source of a published page's map. A post carries its own `address`, `map_embed_code` and `map_share_link` (db/063), and those are what the page renders. This exists so the tenth event at one park does not need someone to paste an embed URL for the tenth time. `map_embed_code` is an iframe `src` (a `/maps/embed?pb=…` URL), not a share link — the two are not interchangeable and neither can be derived from the other. `eventbrite_venue_id` is the difference between a venue an event can publish to Eventbrite from and one it cannot. The Portal creates the venue on first save and caches the id here; nothing in this platform creates one yet, so treat null as "not yet", never as "no". `ora_venue_id` is the location's venue on Ora (db/094), made when the location is saved or by `scripts/ora-venues.ts`. Null means an Ora event there carries just the location's name. Soft-deleted venues are excluded by default, matching the Portal's own `WHERE is_removed = 0`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
include_deleted (query) |
No | string |
true, false |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/content/custom-locations
CMS
Save a venue (create, or update the one with that name)
A port of the Portal's saveCustomLocation(), which is an UPSERT KEYED ON THE NAME, not a create. Scope: `content:write`. 201 when the name was not there, 200 when it was. The name is compared case-insensitively, because the Portal's MySQL `=` is and a Postgres `=` is not — without the fold "Bell District" would become a second venue here and stay one there. Saving a name that was soft-deleted REVIVES that row, keeping its id and its cached `eventbrite_venue_id`. That is the Portal's `is_removed = 0`, and it is why deleting is never a row delete. `eventbrite_venue_id` is NOT accepted from the caller. It is Eventbrite's id, and a client able to set it could point a venue at somebody else's. **`eventbrite_action` on the response** says whether an Eventbrite venue was due, on the Portal's rule — new location, or the map changed: * `none` — nothing was due: the map did not change, or there is no map to resolve an address from. * `needed` — a venue was due, and creating it was attempted. **`eventbrite_outcome`** then says what came of the attempt, and is null when none was made: * `created` — the venue exists and `eventbrite_venue_id` is set. This location can publish to Eventbrite. * `not_configured` — `EVENTBRITE_PRIVATE_KEY` / `EVENTBRITE_ORGANIZATION_ID` are unset on this deployment. * `no_address` — the map URL did not resolve to a postal address. Eventbrite needs a street, city, region, postcode and a pin; a share link or a blank map cannot supply one. * `refused` — Eventbrite answered without an id. See `eventbrite_detail`. * `unreachable` — the request to Eventbrite or Google failed. A failure here never fails the save, and never clears an `eventbrite_venue_id` the location already had. A location that cannot reach Eventbrite is still worth having in the list, and losing a working venue id to a timeout during a map edit would be worse than not refreshing it. Note there is no way to DELETE an Eventbrite venue through their API (`DELETE /venues/{id}/` answers 405; it is UI-only). That is why a venue is only created when one is genuinely due — the alternative is an organization slowly filling with duplicates that nothing can clean up. **`ora_outcome`** is the same for the location's Ora venue, attempted when `ORA_API_KEY` is set, there is a map, and either an Eventbrite venue was due (new location, or the map changed) or the location has no `ora_venue_id` yet. Null when nothing was attempted; otherwise: * `created` / `updated` — the venue exists on Ora and `ora_venue_id` is set. Ora dedupes on the key `flow-custom:<id>`, so a changed map moves the same venue rather than making another. * `no_address` — the map did not resolve to an address. Nothing is sent (Ora asks not to create a venue just to name a place); events there carry the name. * `refused` — Ora said no. See `ora_detail`. Non-fatal like the Eventbrite venue, and an existing `ora_venue_id` is never cleared on failure.
/v1/content/custom-locations/{id}
CMS
Soft-delete a venue
Sets `deleted_at`, the Portal's `is_removed = 1`. Scope: `content:write`. Never a row delete. A post names a venue by NAME and keeps its own copy of the map, so dropping the row would throw away the Eventbrite venue id and the Publisher id belonging to a place that published events still say they were held at. Deleting an already-deleted venue is a 200, not an error, and the original `deleted_at` is kept — a double-submit cannot rewrite when it happened. Saving the same name again revives this row.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
id (path) |
Yes | string (uuid) |
— |
/v1/content/ai/description
CMS
Write a post description from an author's rough bullets
A port of the Publisher's `generate_ai_description`: an author pastes rough notes and a model writes the description. Scope: `content:write`. Same model and settings as the Publisher — **grok-4** at `max_tokens: 500`, `temperature: 0.7` — because the system prompt was tuned against them, and someone moving from the Publisher to Rally should get the same kind of writing back. That prompt says "event" even for a retreat or a training, exactly as the Publisher's does. **`content:write`, not `content:read`.** Nothing is written to `funnel_content`, but every call spends money on an external API, and the read scope is handed out broadly (db/067 gave it to every key holding `contact:read`). **The response carries `html`, already escaped.** The Publisher converts Markdown in the browser with a regex chain and no escaping, so anything the model emits that looks like a tag becomes live markup in the editor. Here the output is escaped first and only this service adds tags, so `<script>` from a model comes back as text. `markdown` is returned alongside for a client that formats it differently. Errors are never a 500 — each failure is a known state: * `503` `not_configured` — `XAI_API_KEY` is unset on this deployment * `503` `unreachable` — the request to xAI failed or timed out * `502` `refused` — xAI answered with an error * `502` `empty` — a 200 carrying no text (check `finish_reason`; `length` means the 500-token ceiling truncated it)
/v1/content/images
CMS
Flow's image library
The catalog behind the Publisher's image gallery and its "Search Flow's image library" box — a mirror of the Portal's `portal_dam` (db/078). Scope: `content:read`. **Metadata only.** Every picture is already served publicly by Cloudflare Images, so each row carries a ready-made `url` at the gallery's width. A caller wanting another size swaps the trailing variant — `/w=1200` for a hover preview, `/w=1000` for a hero, which is what the Portal does. Three ways to ask, and never more than one at a time: * `keywords=gong,sound` — SCORED, best first. A match on `tags` counts 3, on `description` 2, on `image_name` 1, which is the Publisher's `searchImages()` weighting: a human typed the tags, a model wrote the descriptions, and the file name is whatever the camera called it. Rows scoring zero are excluded. This is what "find more images" pages through, using the keywords `/images/suggest` already returned. * `q=cedar` — a plain "does this word appear" over those same three columns, newest first. The library box. * neither — the whole catalog, newest first. Matching is case-insensitive (`ILIKE`), because `portal_dam`'s MySQL collation is and Postgres's is not — a faithful `LIKE` would quietly return less for the same words. `%` and `_` in a term are escaped, so a stray wildcard cannot match the entire catalog. `orientation=horizontal` is how a caller asks for hero-shaped pictures. Deliberately not the default here: this endpoint is the library, and something wanting a portrait crop should not have to argue with it. `/images/suggest` defaults it the other way. **The mirrored rows change only when the loader runs** (`ops.yml -f action=sync -f sync_job=publisher:images`), not when somebody uploads to the Portal's DAM. Pictures this platform accepted itself arrive through `POST /v1/content/images` and are in the same catalog; `origin` says which is which.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
keywords (query) |
No | string |
Comma-separated, or the param repeated. At most ten; each is lowercased, trimmed and de-duplicated. Mutually exclusive with q. |
q (query) |
No | string |
Plain substring match. Mutually exclusive with keywords. |
orientation (query) |
No | string |
horizontal, vertical, square |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/content/images
CMS
Put a picture in the library
Stores one image under the uploads root and records it in `funnel_content.image_asset` (db/081). Scope: `content:write`. The library was readable and unwritable until this existed, because db/078 mirrors the Portal's `portal_dam` and the Portal has no INSERT into that table anywhere in its tree. Editor controls were waiting on it: a hero could not accept a photograph, every "Upload image" slot said so and could not, and a picture xAI drew was a `data:` URL that lived only in the browser tab that asked for it. **The bytes go on disk, not into Cloudflare Images.** The mirrored DAM lives there and stays there; new uploads go where the Publisher has always put them, which is also where 586 of 686 live post heroes already point. The same Cloudflare transform resizes both. **The directory says what the file is** — `events/hero/`, `retreats/destination/`, `headshots/`. It is built from `role` and `post_type` against allowlists, never from a string a caller spells, so traversal is impossible by construction. A pair that does not name a real place is a 400 and not a fallback. `post_type` is create-only, so a path derived from it can never go stale. **The body carries the image as a base64 `data:` URL**, not `multipart/form-data`. The editor already holds its pictures that way, and this service takes no multipart dependency. Base64 is 4 bytes per 3, so the request limit is 16 MB for a 10 MB image, enforced on the DECODED bytes. JPEG, PNG, WebP and GIF. SVG is refused: it is a document, it can carry script, and it has no pixel dimensions to read. `width`, `height` and `orientation` are read from the file's own header bytes, because `orientation` is what `/images/suggest` filters on. Bytes whose dimensions cannot be read are still stored, with those columns left null. `origin` is declared by the caller because only the caller knows — the same bytes arrive the same way whether a person chose a file or kept a generated picture. `portal_dam` is refused; a client cannot claim to be the mirror. Returns **201** and a single image in the shape `GET /v1/content/images` returns: `asset_id` is what a post stores, `url` is where to render it from.
/v1/content/images/suggest
CMS
Pictures for an event title
The Publisher's `action=suggest`: an author types an event title and the gallery fills. Scope: `content:write`. The title goes to xAI, which answers with keywords **from the DAM's own tag vocabulary**, and those run the scored search above. That vocabulary is listed in the prompt on purpose — nobody tags a photo "Full Moon Sound Bath", they tag it "gong", "sound", "meditation", so without the list the model returns good English that matches nothing. **grok-4-fast-non-reasoning**, `temperature: 0.2`, `max_tokens: 50`, 10s timeout — all the Publisher's. A small, cheap model because this runs on every pause in typing rather than on a button press, and a low temperature because the same title should keep returning the same pictures. **`keywords` comes back with the images**, and that is not decoration: a caller holds them so "find more" can page the same search through `/v1/content/images?keywords=…` without paying for a second model call. `orientation` defaults to `horizontal` here — a suggestion is for a hero, and the Portal hard-codes it. Pass `any` for every orientation. **`content:write`, not `content:read`**, matching `/ai/description` and for the same reason: it spends money on an external API on every call, and the read scope is handed out broadly (db/067). Reading the catalog is a read; asking a model about it is not. Errors are never a 500 — each failure is a known state: * `503` `not_configured` — `XAI_API_KEY` is unset on this deployment * `503` `unreachable` — the request to xAI failed or timed out * `502` `refused` — xAI answered with an error * `502` `empty` — no usable keywords in the reply
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
title (query) |
Yes | string |
The event title. Required and non-blank. |
orientation (query) |
No | string |
horizontal, vertical, square, any |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/content/ai/images
CMS
Draw five pictures for an event title
The Publisher's "Generate with AI", for an event that has no photograph. Scope: `content:write`. **xAI, not Google.** The standalone `publisher-images.php` uses Gemini and it is easy to read that file and conclude this is a Google integration; the one the editor actually calls is `grok-imagine-image`. That is why this needs no new secret — `XAI_API_KEY` is already here for `/ai/description`. Five looks in one call, so an author gets a choice rather than one image to take or leave. Model, styles and prompt template are the Publisher's verbatim: 16:9 because the result is a hero banner, a singular minimal subject because a busy image loses to the title text laid over it, and "no text or words" because a generated sign reading FLOW YOGE is the commonest reason one of these is unusable. **Some pictures are drawn and then blocked by xAI's own content filter** — `imagine:content-moderated`, a 400, and billed. Measured at one in five for an ordinary yoga event title, so it is routine rather than exceptional; the response carries `moderated` so a caller that asked for five and got four can say why. If EVERY picture is blocked the answer is a 502 `refused` rather than a 503, because that is about the title's wording and not about the service being down. **Partial success is success.** Five independent calls to a generative API will sometimes not all land, and four pictures is a useful answer where an error is not. Only an empty set is a failure. Measured against the live API, one generation is ~7.5s for a 169 KB JPEG; the five run concurrently. **The pictures come back inline, as `data:` URLs, and that is not a storage decision.** The Portal writes the bytes into its own web root and hands back a relative path; this service has no such directory and should not grow one. Flow's images live in Cloudflare Images, which is where a generated one belongs — but that upload needs an account id and an Images-scoped token this deployment does not have, and there is no existing Cloudflare upload anywhere in the Portal to port, so writing one blind would be untested code on a paid path. `storage` on the response says which it is: `inline` today, and a delivery URL when those two values land. Nothing else about the shape changes.
CMS · cloudtalk 4
/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. Each row carries an `intelligence` object — CloudTalk's Conversation Intelligence output (`call_type`, `sentiment`, `score`, `talk_ratio`, `topics[]`, its own AI `summary`, `has_transcript`) — or `null` when the call has none. Null is ordinary, not an error: CloudTalk only analyses calls with real talk time, so anything under roughly 30 seconds has no analysis and never will. Within the object, `analysed: false` means we asked and CloudTalk had nothing. Voicemails and missed inbound calls with a recording and no transcript are transcribed automatically by the same `cloudtalk-calls` job that syncs the CDRs (Deepgram Nova-3). Top-level `has_transcript` / `transcript_source` report that text; `transcript_source` is `deepgram` for those rows. The job never overwrites a CloudTalk Conversation Intelligence transcript on an answered call. Without the Deepgram key the pass is skipped (fail closed) and CDR ingest still runs. `said=<text>` searches the transcript text itself — the calls where somebody actually said that word. The transcript is not included in list rows (50 calls must not carry 50 transcripts); `has_transcript` tells you it exists, and `/{call_id}/transcript` returns it.
| 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 |
sentiment (query) |
No | string |
positive, neutral, negative |
ai_call_type (query) |
No | string |
support, sales, other |
min_score (query) |
No | integer |
Minimum overall call score (0–100) |
analysed (query) |
No | boolean |
true = only calls Conversation Intelligence analysed |
said (query) |
No | string |
Substring match against the call transcript — what was actually said |
has_transcript (query) |
No | boolean |
Only calls that do (or do not) have a transcript |
limit (query) |
No | integer |
Page size, 1–1000. Default 50. |
offset (query) |
No | integer |
— |
/v1/cloudtalk/calls/{call_id}
CMS
One call
Full detail for one call — both parties' numbers, studio line, agent, duration, waiting time, recording link, the `intelligence` analysis and `smart_notes` — plus the raw synced `document` and `analytics_document`. The transcript is **not** on this response: its segments, its flattened text and its verbatim payload are the same words three times over, which made opening a detail pane cost tens of KB to show a screen of metadata. Use `/{call_id}/transcript`, which serves one copy and caches for an hour. Top-level `has_transcript` / `transcript_source` tell you whether there is one and who produced it (`deepgram` or `cloudtalk_intelligence`). New voicemails with a recording are filled by the recurring `cloudtalk-calls` Deepgram pass; `transcript_source` is then `deepgram`.
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
call_id (path) |
Yes | string |
— |
/v1/cloudtalk/calls/{call_id}/transcript
CMS
One call's transcript
One call's transcript, so a viewer does not have to pull the whole call detail. `source` is `cloudtalk_intelligence` or `deepgram` — CloudTalk does not transcribe voicemails (`talking_time=0`); those come from Deepgram Nova-3 over the recording WAV, automatically, on the recurring `cloudtalk-calls` job after the CDR upsert. Never invent CloudTalk as the source of a Deepgram transcript. Read from our own synced copy, not proxied live — a transcript never changes once generated, so this caches for an hour. 404 means the call has no transcript yet (job has not landed, or there is no recording).
| Parameter | Required | Type | Allowed values |
|---|---|---|---|
call_id (path) |
Yes | string |
— |
/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 |
— |