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. newapi's combined API docs page (/) fetches this alongside cms-api'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.
Datastream · schedule 9
/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.
/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.
/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.
/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.
/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.
/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.
/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.
/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.
/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
/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.
/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`.
/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.
/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`.
/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`.
/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.
/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
/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`.
/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`.
/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.
/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.
/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.
/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`.
/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.
/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.
/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
/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.
/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
/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.
/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
/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).
/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.
/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
/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
/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.
/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.
/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.
/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.
/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`.
/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.
/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
/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 1
/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
/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.
/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.
/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).
/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.
/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.
/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
/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.
/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.
/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
/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)
/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.
/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).
/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
/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.
/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.
/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.
/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.
/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.
/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.
/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.
/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`.
/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).
/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`).
/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.
/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.
/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
/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.
/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.
/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.