Datastream and CMS API

Specs: dsapi OpenAPI · cmsapi OpenAPI · 76 operations

Datastream · ops 4
GET /status Datastream

Health check

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

GET /status/schema-assumptions Datastream

Fields resting on unverified JSON paths

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

GET /v1/docs Datastream

This document

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

GET /v1/stream Datastream

Live change stream (SSE)

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

Datastream · schedule 9
GET /v1/schedule Datastream

Class schedule

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

GET /v1/schedule/{class_id} Datastream

One class

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

GET /v1/class-descriptions/{class_schedule_id} Datastream

One class type's description

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

GET /v1/schedule/class Datastream

One class (legacy query-param form)

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

GET /v1/schedule/{class_id}/roster Datastream

Class roster

Backed by `ds_mbo.class_visit`, joined to `ds_mbo.client` on `(client_id, site_id)` — never on id alone, since MBO ids collide across sites. Freshness caveat: PLAN.md §7b wants enrollments within 5 minutes, which the mirror's 3×/day sync does not yet meet. Confirm before repointing check-in.

POST /v1/schedule/{class_id}/bookings Datastream

Book a client into a class

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

DELETE /v1/schedule/{class_id}/bookings Datastream

Remove a client from a class

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

POST /v1/schedule/{class_id}/roster/{visit_id}/check-in Datastream

Mark a booked client signed in (or undo it)

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

GET /v1/schedule/roster Datastream

Class roster (legacy query-param form)

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

Datastream · sales 7
POST /v1/purchases Datastream

Check out a catalog item for a client

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

GET /v1/sales Datastream

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

One row per sale with item_count / payment_count / sale_total / payment_total rollups — most reads never need the line tables. `sale_date` is date-only; `transacted_at` is the datetime when known (prefer it for clocks/sorting). Requires at least one narrowing filter. `adjusted=true` returns 400 until sale_adjusted exists (PLAN.md §9): refusing loudly beats silently serving unadjusted numbers. A sale made through `POST /v1/purchases` appears here before the mirror has it, carrying `source: "pending_write"` and counted in `total_count`. It retires the moment the sync carries the same sale id. Such a response is `Cache-Control: no-store`. The same applies to `/v1/sales/{id}`, `/{id}/items` and `/{id}/payments`.

GET /v1/sales/items Datastream

Line items for multiple sales in one call (batch)

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

GET /v1/sales/payments Datastream

Payments for multiple sales in one call (batch)

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

GET /v1/sales/{sale_id} Datastream

One sale header

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

GET /v1/sales/{sale_id}/items Datastream

Line items of a sale (revenue earned)

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

GET /v1/sales/{sale_id}/payments Datastream

Payments of a sale (money received)

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

Datastream · packages 9
POST /v1/contracts/{contract_id}/purchases Datastream

Sell a membership to a client

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

GET /v1/packages Datastream

Purchasable services and packages

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

GET /v1/contracts Datastream

The contract catalog — memberships and autopays a client can buy

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

GET /v1/contracts/{contract_id} Datastream

One contract from the catalog

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

GET /v1/contracts/{contract_id}/description Datastream

One contract's description

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

GET /v1/products Datastream

The retail catalog — merchandise, not class packs

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

GET /v1/products/{product_id} Datastream

One retail product

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

GET /v1/promo-codes Datastream

Promo code definitions and their terms

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

GET /v1/promo-codes/{promo_code_id} Datastream

One promo code

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

Datastream · staff 2
GET /v1/staff Datastream

Staff and instructors

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

GET /v1/staff/{staff_id}/bio Datastream

One staff member's biography

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

Datastream · locations 2
GET /v1/locations Datastream

Studio locations

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

GET /v1/rooms Datastream

Bookable rooms at the site

`ds_mbo.resource` — `{room_id, name}`. MBO returns nothing else on `/site/resources`: **no capacity and no location**. Room capacity lives in the Portal's own `cms_rooms_capacity`, and the room↔location pairing is only observable through classes, which carry both.

Datastream · events 3
GET /v1/events Datastream

Enrollments, workshops, and events

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

GET /v1/events/{event_id} Datastream

One event

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

GET /v1/events/detail Datastream

One event (legacy query-param form)

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

Datastream · config 1
GET /v1/sites Datastream

Tenant registry for this key

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

Datastream · clients 7
GET /v1/clients Datastream

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

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

GET /v1/clients/{client_id}/enrollments Datastream

Bookings — upcoming, or a window of visit history

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

GET /v1/clients/{client_id}/credits Datastream

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

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

GET /v1/clients/{client_id}/services Datastream

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

Every client_service row: packs, unlimited periods, intro offers. `is_current` splits active vs past; includes `service_name`, `remaining_count`, `total_count`, `active_date`, `expiration_date`, `program_name`.

GET /v1/clients/{client_id}/contracts Datastream

Contracts / autopay memberships for a client

What the client **holds** (not the sellable catalog at `/v1/contracts`). Fields include `contract_name`, `autopay_status`, `agreement_date`, `start_date`, `end_date`. Use autopay_status / end_date to separate active memberships from past ones.

GET /v1/clients/{client_id}/transactions Datastream

Payment transaction history for a client

Card charges for the client: `amount`, `status`, `transaction_time`, `card_type`, `cc_last_four`, `is_settled`. Card expiration month/year are deliberately NOT mapped anywhere in this service and can never appear.

CMS · ops 2
GET /status CMS

Liveness and database connectivity

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

GET /v1/docs CMS

This OpenAPI document (JSON)

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

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

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

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

CMS · contacts 6
GET /v1/contacts CMS

Find contacts

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

GET /v1/contacts/{contact_id} CMS

A single contact

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

GET /v1/contacts/{contact_id}/identities CMS

Every external id resolving to this contact

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

GET /v1/contacts/{contact_id}/visits CMS

This person's page views

Matched via: 1. `tracking_visitor` identities (cookie stitch), and/or 2. `(site_id, client_id)` on the visit matching an `mbo_client` identity (HubSpot page-visit backfill and identified tracker hits). **No cross-source dedupe** — filter with `source` when totals matter. Newest first.

GET /v1/contacts/{contact_id}/profile CMS

Full profile for one person

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

GET /v1/contacts/{contact_id}/activity CMS

Marketing/web activity feed for one contact

Chronological CMS events for a person timeline (newest first): | kind | Source | |---|---| | `page_view` | `cms_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 `cms_hubspot.email_event` (not live HubSpot at request time).

CMS · conversations 3
GET /v1/conversations CMS

Inbox threads (newest first)

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

GET /v1/conversations/{thread_id} CMS

One conversation thread

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

GET /v1/conversations/{thread_id}/messages CMS

Messages in a thread (chronological)

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

CMS · sms 4
GET /v1/sms CMS

Outbound marketing SMS (CRM Communications)

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

GET /v1/sms/blasts/{blast_id} CMS

One blast + recipient sends

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

GET /v1/sms/crm/{hs_object_id} CMS

One outbound marketing SMS by HubSpot object id

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

GET /v1/sms/threads/{thread_id} CMS

Legacy conversation-style SMS thread detail

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

CMS · hubspot 13
GET /v1/hubspot/lists CMS

HubSpot lists / segments (export)

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

GET /v1/hubspot/lists/{list_id} CMS

One list + flattened filters

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

GET /v1/hubspot/lists/{list_id}/members CMS

Members of a list

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

GET /v1/hubspot/forms CMS

HubSpot form definitions

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

GET /v1/hubspot/forms/{form_id} CMS

One form (fields + metadata)

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

GET /v1/hubspot/forms/{form_id}/submissions CMS

Submissions for a form

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

GET /v1/hubspot/forms/{form_id}/submissions/{conversion_id} CMS

One form submission

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

GET /v1/hubspot/emails CMS

Marketing email campaigns

From `cms_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 `cms_hubspot.email_event`, filled by the HubSpot email-events sync). Default console filter is `published=true` (batch + automated + AB), not only `state=PUBLISHED`.

GET /v1/hubspot/emails/{email_id} CMS

One marketing email + HTML body preview

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

GET /v1/hubspot/ctas CMS

HubSpot CTAs (static UI export)

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

GET /v1/hubspot/ctas/{cta_id} CMS

One CTA

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

GET /v1/hubspot/sms-campaigns CMS

HubSpot Marketing SMS campaigns

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

GET /v1/hubspot/sms-campaigns/{sms_campaign_id} CMS

One Marketing SMS campaign

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

CMS · cloudtalk 3
GET /v1/cloudtalk/calls CMS

CloudTalk call history

Every call (incoming/outgoing, answered/missed/voicemail) synced from CloudTalk's own REST API into `cms_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.

GET /v1/cloudtalk/calls/{call_id} CMS

One call

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

GET /v1/cloudtalk/calls/{call_id}/recording CMS

Stream a call's recording audio

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