{"openapi":"3.1.0","info":{"title":"Funnel API","version":"1.0.0","description":"Versioned read path into the Funnel platform: the contact identity spine,\nfirst-party website tracking, HubSpot export data (lists, forms, marketing\nemails, CTAs, conversations, SMS, **email send/open/click events**), and\ntenant config.\n\n**Contact activity.** `GET /v1/contacts/{id}/activity` is the marketing/web\nhalf of a person timeline (page views, forms, chat, SMS, email engagement).\nStudio class visits and purchases stay on datastream-api.\n\nConventions are identical to\n[datastream-api](https://github.com/FlowStudios/datastream-api) — same\nenvelope, same error shape, same paging and parameter rules — so a consumer\nof both never has to branch on which API replied.\n\n**Tenancy.** A key is bound to exactly one tenant and cannot widen its own\nscope. Enforcement is two independent layers: a `TenantScope` that only the\nauth layer can mint, and Postgres row-level security underneath it. A\nhandler that forgot its filter still returns nothing.\n\n**Scopes are per-resource-family and narrow on purpose.** Most marketing\nconsoles need `contact:read`; tenant metadata needs `config:read`.\n\n**Auth.** `Authorization: Bearer funnel-…` on every `/v1/*` route. `/status`\nand `/v1/docs` are open.\n\n## Data freshness\n\nEvery resource here is either **written directly by a first-party system\nin real time**, or **synced from an external system on its own schedule**\n(`scripts/*-sync.ts`, wired into cron via `scripts/*-cron.sh`). There is no\nwebhook receiver anywhere in this API — every external sync is this\nservice reaching out and pulling, not the other way around. Confirmed\nagainst the deployed crontab (`deploy/DEPLOYED.md`), not just script\ncomments — those have drifted from what is actually installed more than\nonce.\n\n| Resource | Mechanism | Cadence |\n|---|---|---|\n| `contacts`, `identities` | First-party identity resolution, on write | Not a recurring resync — a contact is resolved once (from an MBO client, a HubSpot contact, or a tracked visitor) and updated as new identities merge in. No batch job re-derives it on a timer. |\n| `visits` (`/v1/contacts/{id}/visits`, `/v1/visits/*`) | First-party tracker | **Real time.** `collect.php` on the portal writes synchronously to `funnel_tracking.page_visit` on every hit — no batching, no cron. |\n| `conversations` | HubSpot sync | Every 15 minutes (`*/15 * * * *`). |\n| `sms` (CRM SMS threads) | HubSpot sync | Hourly, at :05. |\n| `hubspot/sms-campaigns` | One-shot manual export | **Not scheduled at all.** HubSpot's API does not expose these the same way; a snapshot is loaded by hand and stays fixed until someone re-exports it. |\n| HubSpot email **engagement** (opens/clicks feeding `contacts/{id}/activity`) | HubSpot sync | Hourly, at :20. |\n| `hubspot/emails` (campaign metadata) | HubSpot sync | Nightly, 04:15 UTC. This is the \"once a day\" cadence — true for the campaign asset itself, not for whether someone opened it (that's the hourly job above). |\n| `hubspot/forms` | HubSpot sync | Nightly, 04:15 UTC only — no faster path exists for forms. |\n| `hubspot/ctas` | One-shot manual export | **Not synced at all.** Not reachable with the current HubSpot token's scopes; loaded once from a static export (`scripts/load-hubspot-ctas.ts`) and never refreshed automatically. `cta_source: 'hubspot_ui_export'` on every row marks this explicitly. |\n| `hubspot/lists` | HubSpot sync | Nightly, 04:15 UTC for list definitions; **list membership** is separate and weekly (Sunday 05:30 UTC) — the two can disagree for up to a week on which contacts are actually in a list. |\n| `cloudtalk/calls` | CloudTalk sync | A recurring pull with a 6-hour lookback window (`scripts/cloudtalk-sync.ts`). The pull mechanism is confirmed; the exact interval it runs on in production is not documented anywhere in this repo — treat `cloudtalk/calls` as \"recently synced,\" not pinned to a specific lag. |\n\nEvery synced resource's row carries an explicit `source` (see below) —\ncheck it before treating a number as current, especially for `visits`,\nwhere a large minority of historical rows are a one-time HubSpot backfill,\nnot live tracker data.\n"},"servers":[{"url":"https://funnel.fvmgt.com","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"`Authorization: Bearer funnel-<56 hex>`. Stored as sha512-hex; never recoverable."}},"parameters":{"limit":{"name":"limit","in":"query","description":"Page size, 1–1000. Default 50.","schema":{"type":"integer","minimum":1,"maximum":1000,"default":50}},"offset":{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},"contactIdPath":{"name":"contact_id","in":"path","required":true,"schema":{"type":"string"},"description":"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`.\n"},"siteIdQuery":{"name":"site_id","in":"query","schema":{"type":"string"},"description":"Disambiguates an MBO client id across sites (32-char hex)."}},"schemas":{"ListEnvelope":{"type":"object","properties":{"status":{"type":"string","enum":["success"]},"data":{"type":"array","items":{}},"total_count":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}},"ItemEnvelope":{"type":"object","properties":{"status":{"type":"string","enum":["success"]},"data":{"type":"object"}}},"ErrorEnvelope":{"type":"object","properties":{"status":{"type":"string","enum":["error"]},"error":{"type":"object","properties":{"code":{"type":"string","enum":["bad_request","unauthorized","forbidden","not_found","tenant_scope_violation","rate_limited","upstream_unavailable","internal_error"]},"message":{"type":"string"}}}}},"Contact":{"type":"object","properties":{"contact_id":{"type":"string","format":"uuid"},"email":{"type":["string","null"],"description":"Indexed but NOT unique. 27,096 contacts (14.6%) share an address with another contact — MBO family accounts. Never treat this as an identity key.\n"},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"phone":{"type":["string","null"]},"home_site_id":{"type":["string","null"],"description":"32-char hex, same id space as `ds_config.sites._id`."},"marketable":{"type":"boolean"},"lifecycle":{"type":["string","null"]},"first_seen_at":{"type":["string","null"],"format":"date-time"},"last_seen_at":{"type":["string","null"],"format":"date-time"},"duplicate_of":{"type":["string","null"],"format":"uuid","description":"Set after a merge; points at the surviving contact. Rows are never deleted."}}},"Identity":{"type":"object","properties":{"id_type":{"type":"string","enum":["mbo_client","hubspot_contact","tracking_visitor","email"]},"site_id":{"type":"string","description":"Part of the key. MBO ids are per-site sequential and collide across sites, so an id alone does not identify a person.\n"},"id_value":{"type":"string"},"is_primary":{"type":"boolean"},"first_seen_at":{"type":"string","format":"date-time"}}},"PageVisit":{"type":"object","properties":{"occurred_at":{"type":"string","format":"date-time"},"page_url":{"type":"string"},"page_path":{"type":["string","null"]},"page_title":{"type":["string","null"]},"referrer":{"type":["string","null"]},"utm_source":{"type":["string","null"]},"utm_medium":{"type":["string","null"]},"utm_campaign":{"type":["string","null"]},"device_type":{"type":["string","null"],"enum":["mobile","tablet","desktop",null]},"source":{"type":"string","enum":["tracker","hubspot_backfill","hubspot_import"],"description":"Which pipeline produced the row. Sources are **not** deduped — a tracker hit and a HubSpot import of the same page stay separate rows.\n"},"client_id":{"type":["string","null"]},"site_id":{"type":["string","null"]},"visitor_id":{"type":"string"}}},"ActivityEvent":{"type":"object","description":"One row in the contact activity feed. `kind` is the stable machine label; `status` is free-form (e.g. tracker source, SENT/OPEN, Delivered).\n","properties":{"kind":{"type":"string","enum":["page_view","form_submission","chat","sms_thread","sms_campaign","email_sent","email_open","email_click","email_bounce"]},"occurred_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"detail":{"type":["string","null"]},"status":{"type":["string","null"]},"meta":{"type":"object","additionalProperties":true,"description":"Kind-specific extras. Page views include `source` / `page_path`; email kinds include `email_campaign_id`, `content_id`, `subject`, and `delivered` (boolean) on `email_sent`.\n"}}},"ContactActivity":{"type":"object","properties":{"contact":{"$ref":"#/components/schemas/Contact"},"events":{"type":"array","items":{"$ref":"#/components/schemas/ActivityEvent"},"description":"Newest first, capped by `limit`."},"counts":{"type":"object","additionalProperties":{"type":"integer"},"description":"Count of each `kind` in the returned (clipped) events array."},"event_total":{"type":"integer"},"truncated":{"type":"boolean"},"hubspot_contact_ids":{"type":"array","items":{"type":"string"}}}}}},"paths":{"/status":{"get":{"tags":["ops"],"summary":"Liveness and database connectivity","description":"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).\n","security":[],"responses":{"200":{"description":"Healthy"},"503":{"description":"Database unreachable"}}}},"/v1/docs":{"get":{"tags":["ops"],"summary":"This OpenAPI document (JSON)","description":"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.\n","security":[],"responses":{"200":{"description":"OpenAPI 3.1 spec"}}}},"/v1/tenants/me":{"get":{"tags":["config"],"summary":"The tenant this key belongs to, with its sites and origins","description":"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.\n","responses":{"200":{"description":"Tenant","sites":null,"origins":null,"and key scopes":null},"403":{"description":"Missing `config:read`"}}}},"/v1/contacts":{"get":{"tags":["contacts"],"summary":"Find contacts","description":"Free-text `q` dispatches on shape: UUID → contact_id, all digits → MBO\nclient id, contains `@` → email, formatted phone → last-10 digits of\n`phone`, otherwise name substring.\n\nWithout `q`, at least one of `email`, `external_id`, `marketable`, or\n`modified_since` is required — an unfiltered scan of ~100k+ contacts is\nrejected.\n\n`external_id` resolves through `contact_identity` and requires `id_type`,\nbecause ids from different sources collide.\n","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Free-text: uuid, MBO client id, email, formatted phone, or name."},{"name":"email","in":"query","schema":{"type":"string"},"description":"Case-insensitive. May return several people — family accounts share an address."},{"name":"external_id","in":"query","schema":{"type":"string"}},{"name":"id_type","in":"query","schema":{"type":"string","enum":["mbo_client","hubspot_contact","tracking_visitor","email"]}},{"$ref":"#/components/parameters/siteIdQuery"},{"name":"marketable","in":"query","schema":{"type":"boolean"}},{"name":"modified_since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"include_duplicates","in":"query","schema":{"type":"boolean","default":false},"description":"Include contacts merged into another."},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of Contact"},"400":{"description":"No narrowing filter","or an unknown parameter":null},"403":{"description":"Missing `contact:read`"}}}},"/v1/contacts/{contact_id}":{"get":{"tags":["contacts"],"summary":"A single contact","description":"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.\n","parameters":[{"$ref":"#/components/parameters/contactIdPath"},{"$ref":"#/components/parameters/siteIdQuery"}],"responses":{"200":{"description":"Item envelope of Contact"},"404":{"description":"No such contact. RLS makes a contact in another tenant indistinguishable from one that does not exist.\n"}}},"patch":{"tags":["contacts"],"summary":"Edit a contact's name, email, or phone","description":"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`.\n","parameters":[{"$ref":"#/components/parameters/contactIdPath"},{"$ref":"#/components/parameters/siteIdQuery"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"first_name":{"type":"string"},"last_name":{"type":"string"},"email":{"type":"string","format":"email"},"phone":{"type":"string"}}}}}},"responses":{"200":{"description":"Item envelope of Contact plus `source`"},"400":{"description":"Empty body","unknown field":null,"or a value normalize rejected":null},"403":{"description":"Missing `contact:write` / `contact:read`"},"404":{"description":"No such contact"}}}},"/v1/contacts/{contact_id}/identities":{"get":{"tags":["contacts"],"summary":"Every external id resolving to this contact","description":"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).\n","parameters":[{"$ref":"#/components/parameters/contactIdPath"},{"$ref":"#/components/parameters/siteIdQuery"}],"responses":{"200":{"description":"List envelope of Identity"}}}},"/v1/contacts/{contact_id}/visits":{"get":{"tags":["contacts"],"summary":"This person's page views","description":"Matched via:\n\n1. `tracking_visitor` identities (cookie stitch), and/or\n2. `(site_id, client_id)` on the visit matching an `mbo_client` identity\n   (HubSpot page-visit backfill and identified tracker hits).\n\n**Single-source by default.** `source` defaults to `blended`, which serves\neach instant from exactly one feed: HubSpot page-view history before the\ntenant's first tracker hit, the tracker from there on. HubSpot rows at or\nafter that cutover are validation data (see\n`/v1/visits/reconciliation`) and are left out, so a total can never\ndouble-count the handoff. `source=all` returns the raw union,\n`source=hubspot` both HubSpot feeds, and a single source key just that\nfeed. Newest first.\n","parameters":[{"$ref":"#/components/parameters/contactIdPath"},{"$ref":"#/components/parameters/siteIdQuery"},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"until","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"source","in":"query","description":"Which feed(s) to read. Defaults to `blended`.","schema":{"type":"string","default":"blended","enum":["blended","all","hubspot","tracker","hubspot_backfill","hubspot_import"]}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of PageVisit"}}}},"/v1/visits":{"get":{"tags":["visits"],"summary":"Raw identified page views for one path","description":"The rows behind a viewer list, newest first — the \"show me the actual\ndata\" pane. When a count looks wrong, this is what it is made of.\n\n**Identified views only.** Every row carries a `client_id`; anonymous\ntraffic is excluded outright. See `/v1/visits/counts`.\n","parameters":[{"name":"url","in":"query","required":true,"schema":{"type":"string"},"description":"A page PATH, e.g. /retreats/costa-rica-may-2024"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":1000,"default":100}},{"name":"source","in":"query","schema":{"type":"string","enum":["blended","all","hubspot","tracker","hubspot_backfill","hubspot_import"]},"description":"Default blended"},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"until","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"List envelope of page_visit rows"}}}},"/v1/visits/counts":{"get":{"tags":["visits"],"summary":"Identified views per page, under a path prefix","description":"One row per page under `path_prefix`: `url`, `views`,\n`distinct_students`, `first_seen`, `last_seen`. Feeds the Publisher's\nVIEWS column and the `/cms/post-visits` console page.\n\n**Identified views only, deliberately.** Every row behind these numbers\ncarries a `client_id`, so `views` is NOT a page's total pageview count\nand will read low against GA or the tracker's own totals. The Portal\nanswers this same question from `cms_clients_posts_visits`, every row of\nwhich carries an `hs_id` — so identified-only is what makes these\nnumbers comparable to the ones staff already know, which is what lets\nthat table be retired.\n\n`distinct_students` is usually the number that matters; `views` divided\nby it is repeat-viewing, not reach.\n\nMatched on `page_path`, not `page_url` — a path is what\n`funnel_content.post.public_url` holds, and grouping on the full URL\nwould split one page's count across variants of it.\n\nA page under the prefix with no identified views is absent rather than\nzero: the caller knows which posts it asked about, and inventing rows\nwould make an empty result indistinguishable from a prefix that matched\nnothing.\n\nNo `limit` — a prefix bounds this to one row per post.\nTwo forms, and they are not interchangeable. `urls=` is a CSV of exact\npaths — what a listing uses, because it knows which posts are on screen\n— and it has always been the straightforward one: `= ANY` is equality,\nequality is leakproof, so the planner pushes it below the row-security\nfilter and uses `db/069`.\n\n`path_prefix=` is the exploratory form, and it took three migrations to\nmake work. It needs `db/070`'s `text_pattern_ops` index, because a btree\nin a non-C collation cannot answer `LIKE 'x%'` at all; it needs `db/071`'s\n`SECURITY DEFINER` function, because `LIKE` is *not* leakproof and under\nRLS the planner will not push it below the row-security filter to reach\nthat index; and it needs `db/073`'s dynamic SQL, because a definer\nfunction is never inlined, so a parameterised pattern is not a plan-time\nconstant and the planner cannot derive the prefix bounds the index scan\nranges on — 19.4s against 30ms for the same body with a literal.\n\nPass one or the other, never both.\n","parameters":[{"name":"urls","in":"query","schema":{"type":"string"},"description":"CSV of exact paths, max 200. Mutually exclusive with path_prefix"},{"name":"path_prefix","in":"query","schema":{"type":"string"},"description":"Must begin with \"/\". Mutually exclusive with urls"},{"name":"group_by","in":"query","schema":{"type":"string","enum":["url"]},"description":"Only \"url\" is supported"},{"name":"source","in":"query","schema":{"type":"string","enum":["blended","all","hubspot","tracker","hubspot_backfill","hubspot_import"]},"description":"Default blended. `all` is the raw union and double-counts the tracker/HubSpot overlap window"},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"until","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"List envelope of per-page counts"},"400":{"description":"Missing or non-path path_prefix","or an unsupported group_by":null}}}},"/v1/visits/viewers":{"get":{"tags":["visits"],"summary":"The identified students who viewed one page","description":"The people behind one row of `/v1/visits/counts`, most views first.\n\nNames come from the contact spine through `contact_identity`, joined on\n**(site_id, client_id)** and never `client_id` alone — Mindbody client\nids are per-site sequential and collide across sites, so joining on the\nid would put one studio's student on another studio's event page.\n\nA `client_id` with no contact row still returns, with null names: it is\na real view by a real person the spine has not resolved yet, and\ndropping it would make this list disagree with the count that opened it.\n","parameters":[{"name":"url","in":"query","required":true,"schema":{"type":"string"},"description":"A page PATH"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":1000,"default":100}},{"name":"source","in":"query","schema":{"type":"string","enum":["blended","all","hubspot","tracker","hubspot_backfill","hubspot_import"]}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"until","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"List envelope of viewers"}}}},"/v1/visits/coverage":{"get":{"tags":["visits"],"summary":"What each website-history feed covers, and where they hand off","description":"Website history arrives from two feeds — HubSpot's page-view export\n(`hubspot_backfill`, `hubspot_import`) and our own cookie\n(`tracker`) — and HubSpot is being cancelled. This reports each feed's\nfirst and last hit, the `blend_cutover` a blended read uses, the\n`overlap` window where a tracker-vs-HubSpot comparison is possible, and\nany `gap` neither feed covered.\n\nRead this before summing anything. Measured for tenant `flow` on\n2026-08-04: HubSpot 2025-04-14 → 2026-01-02 (601,978 rows) then a\n382-row trickle to 2026-04-01, tracker 2026-04-02 → now (787k rows and\ncounting) — contiguous, with no overlap yet.\n","responses":{"200":{"description":"Item envelope with sources","blend_cutover":null,"overlap and gap":null}}}},"/v1/visits/reconciliation":{"get":{"tags":["visits"],"summary":"Which students each feed identified, tracker versus HubSpot","description":"The validation pass to run while HubSpot is still paid for — and it\ncompares **students, not page-view volume**. Volume cannot settle this:\nHubSpot only recorded views for contacts it knew, the tracker also sees\nanonymous traffic, bots and crawlers hit the site constantly, and the\nfront-desk kiosk concentrates many people onto a few identities\n(measured July 2026: 29 `client_id`s absorbed 9,295 mbocheckin.com\nviews).\n\nEach bucket therefore counts distinct Mindbody `client_id`s:\n`both_students`, `tracker_only_students`, and the one that matters,\n`hubspot_only_students` — people HubSpot tied to a visit and we did not.\n`coverage_pct` is both / hubspot_students; `missed_pct` its complement.\n\nMeasured over the full Apr–Aug 2026 overlap: coverage 85–86% every\nmonth, so ~600 students a month are attributed by HubSpot and not by us.\nOf July's 618, 253 arrived with `utm_medium=email` — HubSpot knew them\nfrom an email click, which our cookie cannot see unless they then log in\nor book.\n\n`verdict`: `no_overlap` (no bucket has both feeds — untested, not\npassing), `match`, `drift`, or `not_assessed` for `metrics=visits`.\nDrift is one-sided on purpose: identifying more people than HubSpot is\nnot drift.\n","parameters":[{"name":"date_from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"date_to","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"grain","in":"query","schema":{"type":"string","default":"day","enum":["day","week","month"]}},{"name":"metrics","in":"query","description":"`students` (default) is index-only and allows an 800-day window; `visits` returns volume for context, capped at 120 days, and draws no verdict.","schema":{"type":"string","default":"students","enum":["students","visits"]}},{"name":"tolerance_pct","in":"query","description":"Missed-student share above which a bucket counts as drift. Default 10.","schema":{"type":"number","minimum":0,"maximum":100,"default":10}}],"responses":{"200":{"description":"Item envelope with window","verdict":null,"totals and per-bucket student sets":null},"400":{"description":"Window wider than the cap for this metric set","or an unknown grain/metrics value":null}}}},"/v1/contacts/{contact_id}/profile":{"get":{"tags":["contacts"],"summary":"Full profile for one person","description":"Everything this platform holds on one contact in one response: contact\nrow, identities, consent, tracking summary (by source + recent pages),\nHubSpot properties (human-written fields when matched), and optional\nlive Datastream operational data when configured.\n","parameters":[{"$ref":"#/components/parameters/contactIdPath"},{"$ref":"#/components/parameters/siteIdQuery"},{"name":"skip_datastream","in":"query","schema":{"type":"boolean"},"description":"Skip the live Datastream HTTP call."}],"responses":{"200":{"description":"Item envelope of profile aggregate"},"404":{"description":"No such contact"}}}},"/v1/contacts/{contact_id}/activity":{"get":{"tags":["contacts"],"summary":"Marketing/web activity feed for one contact","description":"Chronological Funnel events for a person timeline (newest first):\n\n| kind | Source |\n|---|---|\n| `page_view` | `funnel_tracking.page_visit` (`status` / `meta.source`: `tracker`, `hubspot_backfill`, …) |\n| `form_submission` | HubSpot form submissions by contact email |\n| `chat` | Conversations threads (web chat channel) |\n| `sms_thread` | Conversations SMS threads |\n| `sms_campaign` | Outbound CRM marketing SMS |\n| `email_sent` | Marketing email SENT (HubSpot `/email/public/v1/events`) |\n| `email_open` / `email_click` / `email_bounce` | Same events API |\n\n**Email delivery is not its own row.** HubSpot `DELIVERED` is folded into\nthe matching `email_sent` as `status: \"Delivered\"` (or `\"Bounced\"`) via\n`email_campaign_id`. A DELIVERED with no SENT in the window still emits\none `email_sent` with status Delivered.\n\nStudio class visits and purchases stay on Datastream\n(`/v1/clients/…/enrollments`, `/v1/sales`).\n\nLinked via MBO client → contact, then HubSpot contact ids (identity or\nemail match on crm_object, including merged vids). Email events match on\ncontact email (`recipient`). Engagement rows are loaded by cron into\n`funnel_hubspot.email_event` (not live HubSpot at request time).\n","parameters":[{"$ref":"#/components/parameters/contactIdPath"},{"$ref":"#/components/parameters/siteIdQuery"},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"until","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","schema":{"type":"integer","default":1000,"maximum":2000}},{"name":"kinds","in":"query","schema":{"type":"string"},"description":"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.\n"}],"responses":{"200":{"description":"Item envelope of ContactActivity","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ItemEnvelope"},{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ContactActivity"}}}]}}}},"404":{"description":"No such contact"}}}},"/v1/conversations":{"get":{"tags":["conversations"],"summary":"Inbox threads (newest first)","description":"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.\n\n**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.\n","parameters":[{"name":"status","in":"query","schema":{"type":"string"},"description":"e.g. OPEN, CLOSED"},{"name":"contact_id","in":"query","schema":{"type":"string"},"description":"HubSpot associated contact id"},{"name":"channel_id","in":"query","schema":{"type":"string"},"description":"Single channel or comma list"},{"name":"channel_ids","in":"query","schema":{"type":"string"},"description":"Alias for multi channel_id"},{"name":"phone","in":"query","schema":{"type":"string"},"description":"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."},{"name":"q","in":"query","schema":{"type":"string"},"description":"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."},{"name":"spam","in":"query","schema":{"type":"boolean"},"description":"true = spam folder only; omit/false = exclude spam (inbox default)"},{"name":"archived","in":"query","schema":{"type":"boolean"},"description":"true = trash only; omit/false = exclude archived (inbox default)"},{"name":"counts","in":"query","schema":{"type":"boolean","default":true},"description":"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."},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of threads"},"403":{"description":"Missing `contact:read`"}}}},"/v1/conversations/stats":{"get":{"tags":["conversations"],"summary":"Inbox volume by local day over a date range","description":"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.\n","parameters":[{"name":"date_from","in":"query","required":true,"schema":{"type":"string","format":"date"},"description":"First local day, YYYY-MM-DD"},{"name":"date_to","in":"query","required":true,"schema":{"type":"string","format":"date"},"description":"Last local day (inclusive), YYYY-MM-DD; at most 366 days after date_from"},{"name":"tz","in":"query","schema":{"type":"string","default":"America/Chicago"},"description":"IANA zone the days are bucketed in"}],"responses":{"200":{"description":"Item envelope","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ItemEnvelope"},{"type":"object","properties":{"data":{"type":"object","properties":{"date_from":{"type":"string","format":"date"},"date_to":{"type":"string","format":"date"},"tz":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","format":"date"},"source":{"type":"string","enum":["hubspot_conversations","cloudtalk"]},"channel_id":{"type":"string","nullable":true,"description":"HubSpot channel; 1008 for calls"},"status":{"type":"string","nullable":true,"description":"OPEN or CLOSED (inbox status)"},"assigned_to":{"type":"string","nullable":true,"description":"HubSpot actor id; null on call rows"},"agent_name":{"type":"string","nullable":true,"description":"CloudTalk agent; null on HubSpot rows"},"call_status":{"type":"string","nullable":true,"enum":["voicemail","missed",null]},"location_name":{"type":"string","nullable":true,"description":"CloudTalk studio line; null on HubSpot rows"},"threads":{"type":"integer"},"ai_drafted":{"type":"integer"},"ai_sent":{"type":"integer"},"replied":{"type":"integer","description":"threads a person (A- actor) replied in; 0 on call rows"},"home_studio":{"type":"string","nullable":true,"description":"the requester's HubSpot home_studio (MBO home location); null on call rows or when unset"}}}},"actors":{"type":"object","additionalProperties":{"type":"string"},"description":"actor id -> display name"},"handlers":{"type":"array","description":"who replied, one row per day / channel / person; a thread counts once per distinct handler","items":{"type":"object","properties":{"day":{"type":"string","format":"date"},"channel_id":{"type":"string","nullable":true},"home_studio":{"type":"string","nullable":true,"description":"the requester's HubSpot home_studio"},"handler":{"type":"string","description":"HubSpot owner name for the agent seat, else the outgoing sender name, else the actor id"},"actor_id":{"type":"string","nullable":true,"description":"A-<agent> is a person, B-<bot> a bot"},"threads":{"type":"integer"}}}}}}}}]}}}},"400":{"description":"Missing or malformed date_from / date_to","window over 366 days":null,"or an unknown tz":null},"403":{"description":"Missing `contact:read`"}}}},"/v1/conversations/{thread_id}":{"get":{"tags":["conversations"],"summary":"One conversation thread","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of thread"},"404":{"description":"Unknown thread"}}},"patch":{"tags":["conversations"],"summary":"Open, close, or mark a thread as spam","description":"`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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["OPEN","CLOSED"]},"spam":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Item envelope of the updated thread"},"400":{"description":"Missing or invalid status or spam"},"404":{"description":"Unknown thread"},"503":{"description":"HubSpot token missing or conversations.write refused"}}}},"/v1/conversations/{thread_id}/intent":{"get":{"tags":["conversations"],"summary":"AI-classified intent for a thread, if one has been recorded","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the intent row","or null if none has been recorded for this thread":null}}}},"/v1/conversations/{thread_id}/messages":{"get":{"tags":["conversations"],"summary":"Messages in a thread (chronological)","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"},{"name":"origin","in":"query","schema":{"type":"boolean","default":true},"description":"When false, skip the synthesized campaign-origin row. The inbox uses this for first paint, then refetches with the default.\n"}],"responses":{"200":{"description":"List envelope of messages"},"404":{"description":"Unknown thread"}}},"post":{"tags":["conversations"],"summary":"Reply on a live-chat, email, form, or SMS thread","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":4000},"type":{"type":"string","enum":["reply","note","forward"],"default":"reply"},"to":{"type":"string","description":"Forward recipient(s)","comma-separated":null},"sender_staff_id":{"type":"string"},"sender_site_id":{"type":"string"},"sender_email":{"type":"string","format":"email"}}}}}},"responses":{"201":{"description":"Item envelope of the HubSpot-created message"},"400":{"description":"Unsupported channel","empty text":null,"or missing agent/recipient":null},"404":{"description":"Unknown thread"},"503":{"description":"HubSpot token missing or conversations.write refused"}}}},"/v1/conversations/{thread_id}/messages/{message_id}/original-content":{"get":{"tags":["conversations"],"summary":"Full forwarded/replied email body from HubSpot","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"message_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope `{ rich_text, text_body }`"},"404":{"description":"Message is not on this thread"},"503":{"description":"HubSpot token missing or upstream refused"}}}},"/v1/conversations/{thread_id}/messages/{message_id}/media":{"get":{"tags":["conversations"],"summary":"Stream Instagram or HubSpot FILE attachment bytes","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"message_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"index","in":"query","schema":{"type":"integer","minimum":0},"description":"FILE attachment index. Omitted prefers Instagram media","then FILE 0.":null}],"responses":{"200":{"description":"Raw image or video bytes"},"404":{"description":"Unknown message","no media":null,"or the signed URL has expired":null},"503":{"description":"Media exceeded the 15MB proxy limit"}}}},"/v1/conversations/{thread_id}/draft":{"post":{"tags":["conversations"],"summary":"Generate an AI-suggested reply for a thread","description":"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).\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"agent_first_name":{"type":"string","maxLength":60}}}}}},"responses":{"201":{"description":"Item envelope of the draft (or the logged escalation)"},"400":{"description":"Spam thread","or no customer message to reply to":null},"404":{"description":"Unknown thread"},"502":{"description":"LLM classification or generation unavailable"}}}},"/v1/conversations/{thread_id}/drafts":{"get":{"tags":["conversations"],"summary":"Draft history for a thread","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of drafts"}}}},"/v1/conversations/{thread_id}/drafts/{draft_id}":{"patch":{"tags":["conversations"],"summary":"Record what the human did with a draft","description":"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\": \"…\" }`.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"draft_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the updated draft"},"400":{"description":"Bad status","or edited without final_text":null},"404":{"description":"No such draft still awaiting an outcome"}}}},"/v1/conversations/{thread_id}/drafts/{draft_id}/approve":{"post":{"tags":["conversations"],"summary":"Approve a SupportBot plan","description":"Body may carry `final_text` (the reply as edited in the console); it is sent instead of draft_text and the outcome records edited.\nRuns 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).\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"draft_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the executed plan"},"400":{"description":"Draft already resolved","or an unwired step":null},"404":{"description":"No such draft"},"503":{"description":"A step failed (Mindbody / Datastream / HubSpot)"}}}},"/v1/conversations/{thread_id}/hide-sender":{"post":{"tags":["conversations"],"summary":"Hide future mail from this sender (not spam)","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the hide"},"400":{"description":"No sender identifier"},"404":{"description":"Unknown thread"}}}},"/v1/conversations/{thread_id}/transcript-email":{"post":{"tags":["conversations"],"summary":"Email this conversation's transcript to the customer","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the send (to","message_count":null,"subject":null,"sent_at)":null},"400":{"description":"Bad address","no recipient":null,"nothing to send":null,"or SendGrid rejected it":null},"404":{"description":"Unknown thread or call"},"503":{"description":"Neither SendGrid nor HubSpot could send"}}}},"/v1/settings/studio-names":{"get":{"tags":["config"],"summary":"Studio display-name overrides","description":"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.\n","responses":{"200":{"description":"List envelope of overrides"}}}},"/v1/settings/studio-names/{source_name}":{"put":{"tags":["config"],"summary":"Set one studio's display name","description":"Body `{ \"display_name\": \"Georgetown\" }`. Creates or replaces; the path name is folded to lower case before matching. Requires config:write.\n","parameters":[{"name":"source_name","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the override"},"400":{"description":"Missing or over-long display_name"}}},"delete":{"tags":["config"],"summary":"Revert a studio to its upstream name","description":"Removes the override. Requires config:write.","parameters":[{"name":"source_name","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the removed override"},"404":{"description":"No override for that name"}}}},"/v1/staff-hubspot":{"get":{"tags":["config"],"summary":"Mindbody staff → HubSpot agent links","description":"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.\n","responses":{"200":{"description":"List envelope of links"}}},"put":{"tags":["config"],"summary":"Connect one staff member to a HubSpot owner","description":"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.\n","responses":{"200":{"description":"Item envelope of the link"},"400":{"description":"Missing staff or a malformed actor id"}}},"delete":{"tags":["config"],"summary":"Disconnect a staff member from HubSpot","parameters":[{"name":"site_id","in":"query","required":true,"schema":{"type":"string"}},{"name":"staff_id","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the removed link"},"404":{"description":"No link for that staff member"}}}},"/v1/staff-hubspot/owners":{"get":{"tags":["config"],"summary":"HubSpot owners who can send inbox replies","description":"Live `/crm/v3/owners` list, owners without a portal userId omitted. `actor_id` is `A-<userId>`. Requires contact:read.\n","responses":{"200":{"description":"List envelope of owners"},"503":{"description":"HubSpot token missing or owners read refused"}}}},"/v1/supportbot/config":{"get":{"tags":["conversations"],"summary":"SupportBot configuration for the tenant","description":"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.\n","responses":{"200":{"description":"Item envelope of the configuration"}}}},"/v1/supportbot/intents/{intent_code}":{"patch":{"tags":["conversations"],"summary":"Toggle an intent's draft allowlist flag","description":"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.","parameters":[{"name":"intent_code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the intent"}}}},"/v1/supportbot/rules":{"post":{"tags":["conversations"],"summary":"Add a house rule","description":"Body: `{ \"rule_text\": \"…\", \"rule_scope\": \"global\"|\"intent:<code>\"|\"channel:<id>\" }`. Requires config:write.","responses":{"201":{"description":"Item envelope of the rule"}}}},"/v1/supportbot/rules/{rule_id}":{"patch":{"tags":["conversations"],"summary":"Activate or deactivate a house rule","description":"Body: `{ \"is_active\": true|false }`. Deactivate rather than delete — history survives. Requires config:write.","parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the rule"}}}},"/v1/supportbot/plans/{plan_code}":{"patch":{"tags":["conversations"],"summary":"Edit a membership plan on the price sheet","description":"Body: any of plan_name, plan_price, plan_terms, renewal_note, plan_is_active. Requires config:write.","parameters":[{"name":"plan_code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the plan"}}}},"/v1/supportbot/brand":{"patch":{"tags":["conversations"],"summary":"Edit drafting behavior","description":"Body: any of draft_min_confidence (0-1), pricing_notes, voice_prompt. Requires config:write.","responses":{"200":{"description":"Item envelope of the brand profile"}}}},"/v1/supportbot/steps/{step_code}":{"patch":{"tags":["conversations"],"summary":"Toggle a next-step type","description":"Body: `{ \"step_is_active\": true|false }`. Requires config:write.","parameters":[{"name":"step_code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of the step type"}}}},"/v1/stream":{"get":{"tags":["conversations"],"summary":"Live change stream (Server-Sent Events)","description":"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.\n\nNot 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.\n","responses":{"200":{"description":"An open event stream (Content-Type: text/event-stream)"},"403":{"description":"Missing `contact:read`"}}}},"/webhooks/hubspot/conversations":{"post":{"tags":["conversations"],"summary":"HubSpot conversation delivery (not for API clients)","description":"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.\n\nAnswers 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.\n","responses":{"200":{"description":"Accepted; `data.accepted` is the number of threads planned"},"401":{"description":"Signature missing","stale or wrong":null},"404":{"description":"This deployment has no webhook secret configured"}}}},"/webhooks/cloudtalk":{"post":{"tags":["conversations"],"summary":"CloudTalk Workflow delivery (not for API clients)","description":"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.\n","responses":{"200":{"description":"Accepted; `data.call_id` is the CloudTalk id planned"},"401":{"description":"Shared secret missing or wrong"},"404":{"description":"This deployment has no CLOUDTALK_WEBHOOK_SECRET"}}}},"/v1/sms":{"get":{"tags":["sms"],"summary":"Outbound marketing SMS (CRM Communications)","description":"Default is marketing/campaign sends (`source=CRM`, `view=sends`).\n\n- `view=blasts` — group similar copy into blasts\n- `source=CONVERSATIONS` — legacy list of channel-1009 threads\n  (prefer `/v1/conversations` for two-way SMS)\n","parameters":[{"name":"source","in":"query","schema":{"type":"string","enum":["CRM","MARKETING","CAMPAIGN","CONVERSATIONS"],"default":"CRM"}},{"name":"view","in":"query","schema":{"type":"string","enum":["sends","blasts"],"default":"sends"}},{"name":"contact_id","in":"query","schema":{"type":"string"}},{"name":"q","in":"query","schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of SMS sends or blasts"}}}},"/v1/sms/blasts/{blast_id}":{"get":{"tags":["sms"],"summary":"One blast + recipient sends","description":"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.\n","parameters":[{"name":"blast_id","in":"path","required":true,"schema":{"type":"string"},"description":"md5 template key from list view"},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"Blast summary + member sends"},"404":{"description":"Unknown blast"}}}},"/v1/sms/crm/{hs_object_id}":{"get":{"tags":["sms"],"summary":"One outbound marketing SMS by HubSpot object id","description":"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).\n","parameters":[{"name":"hs_object_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of SMS message"},"404":{"description":"Unknown message"}}}},"/v1/sms/threads/{thread_id}":{"get":{"tags":["sms"],"summary":"Legacy conversation-style SMS thread detail","description":"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.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Thread messages"}}}},"/v1/hubspot/lists":{"get":{"tags":["hubspot"],"summary":"HubSpot lists / segments (export)","description":"Deliberately not `/v1/segments` (reserved for platform-owned rules).\n\nPer list: `hubspot_size` (what HubSpot reported) vs `member_count` (rows\nexported). They diverge when a list was recalculating mid-export.\n","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Substring match on list name or HubSpot list id"},{"name":"processing_type","in":"query","schema":{"type":"string","enum":["DYNAMIC","SNAPSHOT","MANUAL"]}},{"name":"object_type","in":"query","schema":{"type":"string","enum":["contacts","companies","deals","tickets"]}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of HubSpot lists"},"403":{"description":"Missing `contact:read`"}}}},"/v1/hubspot/lists/{list_id}":{"get":{"tags":["hubspot"],"summary":"One list + flattened filters","description":"`filters` are leaves with `group_label` / `group_operator` for AND/OR\nnesting. `filter_branch` is the raw HubSpot tree.\n","parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"List detail + filters"},"404":{"description":"No exported list with that id"}}}},"/v1/hubspot/lists/{list_id}/members":{"get":{"tags":["hubspot"],"summary":"Members of a list","description":"Stored as HubSpot record ids; names/emails/MBO joined at read time. Null `contact_id` = not yet crosswalked into the spine.\n","parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of members"},"404":{"description":"No exported list with that id"}}}},"/v1/hubspot/forms":{"get":{"tags":["hubspot"],"summary":"HubSpot form definitions","description":"The forms themselves — name, field list, submission/view counts — not the individual responses people typed in. See the `/submissions` sub-resource for those.\n","parameters":[{"name":"q","in":"query","schema":{"type":"string"}},{"name":"published","in":"query","schema":{"type":"string","enum":["true","false"]}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of forms"}}}},"/v1/hubspot/forms/{form_id}":{"get":{"tags":["hubspot"],"summary":"One form (fields + metadata)","description":"One form's full field list (label, name, type, required/hidden) alongside its submission and view counts.","parameters":[{"name":"form_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Form detail"},"404":{"description":"Unknown form"}}}},"/v1/hubspot/forms/{form_id}/submissions":{"get":{"tags":["hubspot"],"summary":"Submissions for a form","description":"Every response to one form, newest first — the field values a visitor actually typed, plus which contact (if any) it resolved to.\n","parameters":[{"name":"form_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"q","in":"query","schema":{"type":"string"}},{"name":"email","in":"query","schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of submissions"}}}},"/v1/hubspot/forms/{form_id}/submissions/{conversion_id}":{"get":{"tags":["hubspot"],"summary":"One form submission","description":"A single response by its HubSpot conversion_id — full field-by-field answers for that one submission.","parameters":[{"name":"form_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"conversion_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Submission detail"},"404":{"description":"Unknown submission"}}}},"/v1/hubspot/emails":{"get":{"tags":["hubspot"],"summary":"Marketing email campaigns","description":"From `funnel_hubspot.asset` kind `marketing_email` (campaign definitions +\nrollup stats). Per-recipient send/open/click history is **not** listed\nhere — use `GET /v1/contacts/{id}/activity` (`email_*` kinds from\n`funnel_hubspot.email_event`, filled by the HubSpot email-events sync).\n\nDefault console filter is `published=true` (batch + automated + AB),\nnot only `state=PUBLISHED`.\n","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Name, id, subject, campaign"},{"name":"state","in":"query","schema":{"type":"string"},"description":"Exact HubSpot state e.g. PUBLISHED, DRAFT, AUTOMATED"},{"name":"published","in":"query","schema":{"type":"string","enum":["true","false"]},"description":"Broader than state=PUBLISHED"},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of marketing emails"}}}},"/v1/hubspot/emails/{email_id}":{"get":{"tags":["hubspot"],"summary":"One marketing email + HTML body preview","description":"Includes performance stats and `body_html` reconstructed from HubSpot content widgets (module HTML, images, buttons, styleSettings).\n","parameters":[{"name":"email_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Email detail with body_html"},"404":{"description":"Unknown marketing email"}}}},"/v1/content/posts":{"get":{"tags":["content"],"summary":"Authored event, retreat and training content","description":"The Publisher's posts, migrated into `funnel_content` (db/061-065) and\nwritten by Rally's editor after cutover. Scope: `content:read`.\n\nReturns ONLY authored content. Nothing Mindbody owns is here — no live\ncapacity, no booked count, no staff bio, no location detail. Compose\nthose, and compose on **`mbo_schedule_id`**:\n\n- `mbo_schedule_id` is the OCCURRENCE — unique per post, and the id\n  Datastream knows as `class_schedule_id` (equal to `event_id` on\n  `/v1/events`). This is the join.\n- `mbo_enrollment_id` is the PROGRAM — 664 distinct values across 2,396\n  posts, one enrollment covering up to 103 of them. Good for \"every\n  occurrence of this\", useless for identifying one.\n\n`capacity_authored` and `spot_status` are what a human typed at publish\ntime, never live figures. So is `booked_authored` (db/090): a current\nattendance somebody typed over Mindbody's, `null` when nobody has.\nPlaces left is `capacity_authored` minus it when set, and minus\nDatastream's `total_booked` on `mbo_schedule_id` when it is not. It is\nwritable like any other scalar, a whole number of zero or more, and\n`null` hands the count back to Mindbody.\n\nSoft-deleted posts are EXCLUDED by default — 2,845 of the 5,384\nmigrated posts are retired, so a caller that forgot the filter would\nshow twice the content it should.\n","parameters":[{"name":"post_type","in":"query","schema":{"type":"string","enum":["event","retreat","training"]}},{"name":"status","in":"query","schema":{"type":"string","enum":["draft","submitted","waiting","published","cancelled"]}},{"name":"site_id","in":"query","schema":{"type":"string"},"description":"MBO numeric site id as the Publisher stored it"},{"name":"mbo_enrollment_id","in":"query","schema":{"type":"integer"},"description":"Every post for one program — the many-occurrence case"},{"name":"q","in":"query","schema":{"type":"string"},"description":"Matches title, hero_title or slug"},{"name":"featured","in":"query","schema":{"type":"string","enum":["true","false"]}},{"name":"is_free","in":"query","schema":{"type":"string","enum":["true","false"]}},{"name":"starts_after","in":"query","schema":{"type":"string","format":"date"}},{"name":"starts_before","in":"query","schema":{"type":"string","format":"date"}},{"name":"include_deleted","in":"query","schema":{"type":"string","enum":["true","false"]},"description":"Default false"},{"name":"order","in":"query","schema":{"type":"string","enum":["start_date","-start_date"]},"description":"Default -start_date"},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of post summaries"}}},"post":{"tags":["content"],"summary":"Create a post","description":"The first write into `funnel_content`. Until this, everything in there\narrived through `scripts/load-publisher-content.ts`. Scope:\n`content:write`.\n\n`post_type` and a non-blank `title` are the only required fields.\nEverything else is filled in over the life of a draft, and refusing a\nhalf-filled one would mean an author loses what they typed the moment\nthey step away — which is the whole point of a draft.\n\n**`status` cannot be `published`.** Publishing is not a status change:\nit creates the Mindbody enrollment and writes the id back, which is\n`POST /v1/content/posts/{post_id}/publish`. A post marked published by\nthis path would read as live in every list while nothing had been\npublished anywhere. `draft` (the default), `waiting` and `submitted`\nare the three an editor can legitimately reach - `waiting` is the\nreview queue Rally submits into, `submitted` the one Publisher V3 sets.\n\n**Omitted, `null` and `\"\"` are three different things.** Omitting a\nfield leaves it alone, `null` clears the column, and an empty string is\nstored as an empty string — the editor sends `\"\"` for a field an author\ncleared, and folding that to NULL would make the read path unable to\ntell them apart.\n\n`prices` and `teachers` are written as given, in array order, which\nbecomes their `position`. `images` is keyed by gallery kind and\nordered within each one; see the field description.\n\nThe slug is derived from the title and suffixed if taken —\n`sound-bath`, `sound-bath-2`. It comes back on the response, so a caller\nnever has to re-read to learn what it got.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["post_type","title"],"properties":{"post_type":{"type":"string","enum":["event","retreat","training"]},"status":{"type":"string","enum":["draft","submitted"],"description":"Default draft"},"title":{"type":"string"},"prices":{"type":"array","description":"Each entry takes name, price, mbo_service_id, is_headline, is_early_bird, early_bird_ends and custom_note. At most one may be the headline.\n","items":{"type":"object"}},"teachers":{"type":"array","description":"Each entry takes staff_id, display_name, bio_html, email and photo_url. photo_url is what THIS POST shows for them (db/082); null means use their Mindbody staff photo, which is the default and what every migrated row means.\n","items":{"type":"object"}},"blocks":{"type":"object","description":"Prose blocks, KEYED BY KIND — faq, instagram, accommodation, itinerary_note, itinerary_day, testimonial, learn or schedule_detail. Each value is an array of objects taking title and body, in display order; for an faq the title is the question. Only the kinds named are replaced, so an editor that renders four cannot delete the other four. An entry with neither a title nor a body is dropped.\n"},"images":{"type":"object","description":"Galleries, KEYED BY KIND — gallery, destination, rooms or itinerary. Each value is an array of objects taking asset_id and caption, in display order. Only the kinds named are replaced, so an editor that renders three galleries cannot delete the fourth. Send a kind as an empty array to mean the author removed them all.\n"}}}}}},"responses":{"201":{"description":"Item envelope with the created post id","slug and status":null},"400":{"description":"A missing or unknown field","a bad value":null,"or a status this path will not set":null},"403":{"description":"The key is missing the content:write scope"}}}},"/v1/content/posts/{post_id}/publish":{"post":{"tags":["content"],"summary":"Publish a post - create its Mindbody enrollment","description":"Scope: `content:write`. No request body.\n\n**This is the one write here that cannot be undone.** It creates a\nreal, bookable enrollment in Mindbody through Datastream\n(`POST /v1/enrollment-definitions`) and then records the\n`class_schedule_id` it returns on the post, as `mbo_schedule_id`,\nalongside `status: published`.\n\nEvery precondition is checked BEFORE Mindbody is called, and a 400\nnames the single thing to fix - a missing MBO enrollment ID, no\nlocation, no day ticked under \"repeats on\", nothing that can pay for a\nplace. Nothing has happened when one of those is returned.\n\nA post that is already published is refused: publishing twice creates\na SECOND enrollment, and nothing can retract the first.\n\nThe AI `intro` and `short_description` are rewritten after this call\nanswers, not during it. grok-4 takes up to 30 seconds and the author\nis waiting on Mindbody, so the work is detached exactly as the Portal\ndetaches it with `fastcgi_finish_request()`. A 200 therefore does NOT\nmean the new copy is in the row - re-read the post a few seconds later.\nIf the model is unreachable the two fields simply keep their previous\nvalues; it can never fail a post that is already live.\n\nAn event's `public_url` is written after this call answers too: the\nstudio's events page, the start date, and the website's slug for the\nMindbody course, e.g.\n`/locations/austin/westgate/events/2026-10-02/authentic-relating` -\nthe address the Portal gave every event it published. It is written\nonly when the post has none, and never changed afterwards, because\npage views are counted on it. Retreats and trainings get none yet.\n\nThe published-notification email goes out once both are done, so it\nquotes the new copy and links to that page. Eventbrite is its own\ncall, below.\n\nTwo more steps run after this call answers, and the email does not\nwait for them. Datastream is asked to re-pull the new enrollment and\nevery date of it into its copy (its `POST /v1/sync/enrollment`), so\n`/v1/events` and `/v1/schedule` show it within seconds rather than at\nits next sync. A price saved without a name gets its Mindbody service's\nname; a name somebody typed is never replaced. A save of a published\npost repeats both - the Datastream refresh coalesced per post - because\nthat save has just changed Mindbody.\n\nLast, once the address, the copy and the price names are written, the\nFlow website is told (its `rally-events-refresh.php`): it drops its\nsaved copies of the event and has Cloudflare forget the event's\nrally-events page and the rally-events list, so they show the post as\nit now is. A save of a published post does the same. Needs\n`WEBSITE_REFRESH_KEY`; without it nothing is sent.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Published. Returns id","status and mbo_schedule_id":null},"400":{"description":"A precondition failed","or Mindbody rejected it. Nothing was created":null},"404":{"description":"Unknown post"},"503":{"description":"The enrollment may exist but could not be recorded. Check Mindbody before retrying"}}}},"/v1/content/ora/status":{"get":{"tags":["content"],"summary":"Whether this deployment can reach Ora","description":"Scope: `content:read`. For Rally's Settings.\n\n`configured` says whether `ORA_API_KEY` is set, and `mode` which of\nOra's two modes it acts in - `test` (Ora's sandbox, free tickets\nonly) or `live`. `reachable` is one call to Ora's `/ping`: false\nwith a `detail` for a wrong or wrong-mode key (401) or one without\nscopes (403), and null when there is no key to try. `ping` is Ora's\nown answer as it came. Never cached, and never the key itself.\n","responses":{"200":{"description":"configured, mode, reachable, detail and ping"}}}},"/v1/content/posts/{post_id}/ora":{"post":{"tags":["content"],"summary":"Create the Ora event for a published post, and publish it","description":"Scope: `content:write`. No request body.\n\nSends the post to Ora's `POST /events` with the post's own id as the\n`external_id`. Ora creates the event as a DRAFT and dedupes on that\nid, so a second call updates the same event (`created: false`) instead\nof making another. Safe to retry. Events only: any other post type\nanswers `reason: not_wanted`.\n\nOnce its picture, place and tickets are on it, the event is published\n(`POST /events/{ora id}/publish`) - ONCE: an event Ora already calls\npublished is left alone, since publishing again re-stamps its\n`published_at`. When Ora will not publish it (one of its gates: a\nstart in the past, no visible ticket tier, the place, a linked venue\nwithout a confirmed booking), or keeps it a draft because the\norganizer account awaits approval, `event_status` stays `draft`, the\nreason comes back in `warnings`, and the next save tries again.\n\nSent: title, summary (the post's `short_description`, else its tagline,\nelse the start of its description, cut at a word to Ora's 300\ncharacters), the description as Markdown with\nthe events-page footer, start and end as UTC instants with\n`timezone: America/Chicago`, `currency: USD` and `template: wellness`.\n\nThe post's hero image follows in two more calls: Ora's `POST /media`\nfetches it from its public address into Ora's `event-flyers` folder,\nthen `PATCH /events/{ora id}` sets it as `image_url` and\n`hero_image_url`. Only when the hero changed - Ora's media store keeps\na new copy on every upload - which `ora_synced.image` records\n(db/093). A post that lost its hero has the event's image cleared. A\npicture that did not land comes back in `warnings`; the event is\nunaffected and the next save tries it again.\n\nThe location goes IN THE CREATE of a new event, and NAMES the place\nrather than linking a venue. Measured by `ora:probe` (2026-10-03):\nlinking one of our venues answers HTTP 500 every way; a named place in\nthe create of a new event is kept; changing it afterwards (PATCH, or\nthe create re-sent) answers 200 and changes nothing. Venues are setup\ndata and serve as the address book: every studio's is made once by\n`scripts/ora-venues.ts` (`ops.yml` job `ora:venues`), keyed\n`flow-studio:<site>:<location>`, and a saved custom location's when\nthe location is saved (`POST /custom-locations`), keyed\n`flow-custom:<id>`. So a new studio event - or one at a custom location\nwith a venue (`ora_venue_id`) - has its venue read (`GET /venues`) and\nis created with\n`location: {mode: Venue, venue_name, venue_address, venue_city}`; a\ncustom location without one - its map did not resolve to an address -\nwith just `venue_name`. Online gets no location. A place that changes\nafter the event exists is said once in `warnings` - change it on Ora -\nand recorded in `ora_synced.location`. A send never creates a venue or\ngeocodes. A venue Ora does not have - a studio before the setup job has\nrun - leaves the event without a place, and is said so in `warnings`.\n\nThe tickets are made ONCE, the first time the event is sent - at\npublish, or on the save that ticks the box on a published post - from\nthe prices the event has then; later edits do not change them\n(`ora_synced.tickets` records that they were made). They are the\nEventbrite listing's tickets: every price on the event becomes one,\nwhatever it is called, named after its Mindbody service, priced in\ncents (`tier_type: free` at $0), with the post's capacity as its\n`quantity` and no sale end date. One difference: a free post gets one\n$0 ticket, `Free` (keyed `<post id>:free`) - the Eventbrite listing\nsells none, but Ora will not publish an event without a ticket. One\n`POST /events/{ora id}/ticket-types` each, keyed\n`<post id>:<mbo service id>` (or `<post id>:p<position>` without a\nservice). An event with no ticket to make yet is looked at again on\nits next save. A ticket that did not land comes back in `warnings`,\nand the next save sends them all again - the key makes that an update\nof any that did land, not a second copy.\n\nWhat Ora answered is recorded on the post as `ora_event_id`,\n`ora_event_slug` and `ora_event_status` (db/092).\n\n**It answers 200 even when nothing was created**, like the Eventbrite\nlisting: the post is live in Mindbody whatever Ora says. When nothing\nwas sent or Ora said no, `reason` is one of `gone`, `not_published`,\n`not_wanted`, `not_configured`, `no_dates`, `refused` (with Ora's own\n`detail`, field list included) or `unreachable`.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"created, event_id, event_slug, event_status, warnings, and reason/detail when nothing was created"},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}/ora/unpublish":{"post":{"tags":["content"],"summary":"Untick Publish to Ora and take the event off Ora","description":"Scope: `content:write`. No request body.\n\nWhat Rally's editor calls after the author confirms unticking\n\"Publish to Ora\" on a published post - the Ora half of\n`/eventbrite/unpublish`. Ora's `POST /events/{id}/unpublish` takes the\nevent back to a draft, keeping its tickets, orders and `published_at`.\nTicking the box again (`/ora/publish`) puts it live again.\n\nALL OR NOTHING: `publish_to_ora` is saved false only once Ora has said\nyes. `action` is `unpublished`, `no_event` (no Ora event; only the box\nwas saved), `failed` (Ora refused - a cancelled, completed or archived\nevent is a 409 - or did not answer; nothing was saved, the box stays\nticked), `not_configured` (nothing saved) or `gone`.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"action, event_id, and detail when Ora refused"},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}/ora/publish":{"post":{"tags":["content"],"summary":"Tick Publish to Ora on a live event and send it now","description":"Scope: `content:write`. No request body.\n\nThe other half of `/ora/unpublish`. The box is saved, then the event\nis sent as `POST /posts/{post_id}/ora` sends it: made if there is\nnone, updated if there is, and published if Ora has it as a draft -\nwhich puts an unpublished event live again (Ora allows that; Eventbrite\ndoes not). Answers as `/ora` does. Nothing is saved for a post that is\nnot a live event: `reason: not_published`, or `not_wanted` with \"Only\nevents go to Ora.\" Where Ora cannot be reached the box stays ticked\nand the next save sends the event again.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"As /ora: created, event_id, event_slug, event_status, warnings, and reason/detail"},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}/eventbrite":{"post":{"tags":["content"],"summary":"Create the Eventbrite listing for a published post","description":"Scope: `content:write`. No request body.\n\nA SEPARATE CALL FROM PUBLISH, matching the Portal, which runs publish\nas three steps and only makes this one when the author ticked\n\"Publish to Eventbrite\". Folding it in would make the author wait on\neight Eventbrite round trips - image uploads included - before hearing\nthat Mindbody accepted the post.\n\n**It answers 200 even when no listing was created.** The post is\nalready live in Mindbody and nothing here can undo that, so this is\nnever what fails a publish. Branch on `created`; when it is false,\n`reason` is one of `gone`, `not_published`, `not_wanted`,\n`already_listed`, `not_configured`, `no_dates` or `refused`.\n\n**`warnings` means the listing exists but part of it did not.** Once\nthe event is created every remaining step - tickets, the hero image,\nthe gallery, the description - is best effort, and the id is recorded\nwhatever happens to them. An Eventbrite event this service has no\nrecord of is the one outcome with no way back.\n\nRefuses a post that already carries an `eventbrite_id`: a second\nlisting would leave two events and one recorded id, with no way to\ntell which one people are buying from. Calls for the same post queue\nbehind each other and behind the save sync, and the id is recorded\nthe moment the event exists, only if the post has none. A request\nthat loses that race deletes its own draft and answers\n`already_listed`.\n\nA post with `is_private` set is created UNLISTED on Eventbrite -\nreachable by its link, kept out of Eventbrite's search - the way the\npost is kept off the website.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"What happened. created, event_id, and reason or warnings"},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}/eventbrite/unpublish":{"post":{"tags":["content"],"summary":"Take a post's Eventbrite listing down and untick Publish to Eventbrite","description":"Scope: `content:write`. No request body.\n\nWhat Rally's editor calls after the author confirms unticking\n\"Publish to Eventbrite\" on a published post, so they hear whether the\nlisting came down rather than trusting the detached save sync.\n\nUNPUBLISH, NOT DELETE: the event, its orders and its attendees\nsurvive. It can only be published again in Eventbrite itself - see\n`/eventbrite/publish` below for why.\n\n**All or nothing.** `publish_to_eventbrite` is saved false only once\nEventbrite has said yes. Branch on `action`:\n\n  * `unpublished` - taken off Eventbrite, box saved unticked.\n  * `already_off` - the listing was not live; only the box was saved.\n  * `no_listing` - the post has no listing; only the box was saved.\n  * `failed` - Eventbrite refused; nothing changed. `detail` says why.\n  * `not_configured` - no Eventbrite keys here; nothing changed.\n  * `gone` - the post went away meanwhile.\n\nAnswers 200 for all of them, like the listing endpoint.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"What happened. action, event_id, and detail when there is one"},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}/eventbrite/publish":{"post":{"tags":["content"],"summary":"Tick Publish to Eventbrite on a published post, creating its listing if it has none","description":"Scope: `content:write`. No request body.\n\nThe other half of `/eventbrite/unpublish`: what Rally's editor calls\nwhen the author ticks \"Publish to Eventbrite\" on a published post. A\npost with no listing gets one created, which is the full listing\nsequence and can take a minute.\n\n**A hidden listing is not published again.** Eventbrite refuses to\npublish an event that has a venue without tax settings\n(`EVENT_TAX_SETTINGS_MISSING`), and has no public API to set them. A\nfirst listing never meets that because it is published before its\nvenue is attached. So a listing that was unpublished answers\n`republish_unavailable`, with a `manage_url` to publish it in\nEventbrite. Eventbrite is asked first: a listing already published\nagain there is `already_live`, and the box is saved.\n\n**All or nothing.** `publish_to_eventbrite` is saved true only for a\nlisting that is live. Edits made while the box was off are sent by a\nsync queued straight after, detached. Branch on `action`:\n\n  * `created` - a new listing was made, box saved ticked. `warnings`\n    lists parts of it that did not land.\n  * `already_live` - the listing is live; only the box was saved.\n  * `republish_unavailable` - the listing is hidden; nothing changed.\n    `detail` and `manage_url` say to publish it in Eventbrite.\n  * `failed` - Eventbrite refused or could not be asked; nothing\n    changed. `detail` says why.\n  * `not_published` - the post is not live; nothing changed.\n  * `not_configured` - no Eventbrite keys here; nothing changed.\n  * `gone` - the post went away meanwhile.\n\nAnswers 200 for all of them, like the listing endpoint.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"What happened. action, event_id, and detail, manage_url or warnings"},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}/clone":{"post":{"tags":["content"],"summary":"Copy a post into a new draft","description":"Scope: `content:write`. The Portal's Clone link, which staff use to run\nthe same retreat or workshop again without retyping a description, six\nprices and four teachers.\n\nBody is optional and takes two fields. `title` is used verbatim; omit\nit for \"<original> Copy\", the Portal's wording. `updated_by` is the\nstaff id doing this, recorded as both author and last editor of the\ncopy. Any other field is a 400.\n\n**The copy is a draft and nothing else has happened** - nothing is\nscheduled in Mindbody, no Eventbrite listing, no page. It carries the\noriginal's content, its prices, teachers, images, schedule, rooms and\nblocks, and none of its identities: `publisher_post_id`, `public_url`,\n`eventbrite_id`, `mbo_schedule_id`, `featured` and the slug history all\nstay with the original.\n\n`mbo_enrollment_id` IS carried, unlike the Portal's clone. It is the\nclass DESCRIPTION - a reusable definition dozens of runs are scheduled\nfrom - and cloning an event to run it again means the same course\nagain. `mbo_schedule_id`, the run itself, is what must not come across.\n\n**Dates are reset to today in Texas**, which is what the Portal does. A\ncopy carrying the original's dates is either in the past, where\nMindbody refuses it, or a plausible date that makes the copy look ready\n- and publishing it would put a second identical run on the same day.\nTimes ARE carried, which the Portal does not do.\n\nRepeatable. The Portal refuses a second clone of one post; here the\nslug simply counts, so the next copy is `-copy-2`.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"The new draft, plus cloned_from"},"400":{"description":"Unknown field in the body"},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}/cancel":{"post":{"tags":["content"],"summary":"Call off a published post","description":"Scope: `content:write`. No request body. A port of the Portal's Cancel\nlink, which only ever appears on a published post - so anything else is\na 400, and there is no un-cancel.\n\n**The post** goes `is_private = true` and `status = cancelled`. The\nPortal has no cancelled status and expresses the whole thing as\n`private = 1`, which is what takes a post off the website; both are\nwritten here so the flag still means what it means and this\nPublisher's own list can say Cancelled.\n\n**Eventbrite** is cancelled - left up and marked Cancelled, which is\nwhat a ticket holder going back to look for it needs to find, rather\nthan deleted or unpublished. `action` says what happened: `cancelled`,\nor `sales_closed` when Eventbrite refuses because money is against it,\nin which case every ticket tier is pinned to what it has already sold\nso nobody else can buy, and `manage_url` is where a person refunds and\nfinishes. Also `none`, `failed`, `not_configured`.\n\n**Ora** is cancelled the same way, in its own call whatever Eventbrite\nsaid: `POST /events/{ora id}/cancel` keeps the event on Ora, marked\nCancelled, with its tickets and orders. `ora.action` is `cancelled`\n(`ora_event_status` becomes `cancelled`), `none` (no Ora event),\n`failed` (Ora refused - a completed or archived event is a 409 - or did\nnot answer; `detail` says which, and the event is as it was) or\n`not_configured`. `event_url` is the event's page on Ora.\n\n**Mindbody is NOT touched.** The enrollment stays live and bookable.\nThe response carries `mindbody.class_schedule_id`, a back-office\n`admin_url` and a note saying so, the same way delete does. The Portal\nbehaves identically while its own dialog claims otherwise. As on\ndelete, Datastream is then asked to refresh its copy of the enrollment,\nwhich only changes anything once Mindbody no longer has it, and the\nFlow website is told, so the event's page stops selling tickets now\nrather than when Cloudflare's copy expires.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The cancelled post, plus eventbrite, ora and mindbody"},"400":{"description":"Not published","or already cancelled":null},"404":{"description":"Unknown post"}}}},"/v1/content/posts/{post_id}":{"get":{"tags":["content"],"summary":"One post, with its blocks, schedule, prices, teachers and images","description":"`post_id` accepts three forms, dispatched on shape, because three\nsystems address the same post three different ways:\n\n- a uuid — funnel's own id, what Rally holds\n- an integer — `publisher_post_id`, how the Portal addresses every post\n- anything else — the slug, how the public site addresses it\n\nReturns the post whatever its state; `deleted_at` is in the response so\na caller that must not render retired content checks it, the same way it\nalready has to check `status`.\n\n`legacy_source` is never returned — it is the migration archive column\n(db/062) and is scheduled to be dropped.\n","parameters":[{"name":"post_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Post detail with child collections"},"404":{"description":"Unknown post"}}},"patch":{"tags":["content"],"summary":"Update a post","description":"A PARTIAL update. Scope: `content:write`.\n\nA field that is not in the body is not touched, which is what lets the\neditor send only what changed and what makes an autosave possible later.\n`null` clears a column; `\"\"` stores an empty string. Those are three\ndifferent instructions, not three spellings of one.\n\n`prices` and `teachers` REPLACE their whole set when present, because\nthe editor holds them as a list and there is no stable id on a row an\nauthor can reorder or delete. Omit them to leave them alone; send `[]`\nto mean the author removed them all.\n\n`images` replaces PER KIND rather than wholesale, because\n`post_image` holds four galleries and no client renders all four —\ntaking the table wholesale would mean an editor showing three of them\nsilently deleting the fourth on every save.\n\n`post_id` accepts the same three forms the GET does — uuid, the integer\n`publisher_post_id`, or the current slug — so a caller that read a post\ncan write it back with what it already holds.\n\n**An update clears `legacy_source`.** db/062 stamps that on every post\nthe loader wrote, and `load-publisher-content.ts` refuses to TRUNCATE\nwhen it finds a post without one. A post the loader wrote and an author\nthen edits here would otherwise keep its stamp, and the next re-export\nwould quietly overwrite the edit. Clearing it turns a silent overwrite\ninto the loader stopping and saying so.\n\nChanging the title re-slugs the post. `post_slug_history` (db/061)\nexists for keeping the old one, but recording it is the publish path's\njob — an unpublished draft has no link anybody holds, so it simply takes\nthe new slug.\n\n**`status` cannot be `published`** — see the create above. To edit a\npost that already is, send NO `status` at all: an update that names\nnone leaves the column untouched. Sending `waiting` instead pulls a\nlive post back into the review queue while its Mindbody enrollment\ncarries on existing.\n\n**Editing a published post writes to Mindbody, and can be refused.**\nThe enrollment is updated FIRST, through\n`PATCH /v1/enrollment-definitions/{class_schedule_id}` on Datastream,\ncarrying the location, room, staff, pay rate, booking status, dates,\ntimes and days. If Mindbody will not take it - the room is already\nreserved at the new time, the staff session expired - this answers 400\nand **nothing is saved**, so the page and the thing people book cannot\nend up disagreeing. Posts with no `mbo_schedule_id` skip it silently:\nthey are published here but were never created in Mindbody.\n\nTwo fields never reach Mindbody on an edit, because its update has no\nplace for them - `class_description_id` (documented as \"overridden if\nsent\") and pricing. Pricing is settable only when the enrollment is\ncreated; afterwards it changes in the back office.\n\n**Editing a published post also updates its Eventbrite listing.**\nDetached, after this answers, and it branches three ways: a post that\nhas a listing and still wants one is UPDATED - name, times, venue,\norganizer, capacity, the quantity on its existing tickets, the hero\nand the description; one that has a listing and no longer wants it\n(`publish_to_eventbrite` turned off) is UNPUBLISHED, not deleted, so\nits orders and attendees survive; one that wants a listing and has\nnone gets it created. A listing that is hidden is never published\nagain from here (Eventbrite asks for tax settings it has no API for),\nonly kept up to date. Only the parts of the listing whose inputs changed since they\nwere last sent are sent (`eventbrite_synced`, db/091), so a save that\nmoves the title does not upload the gallery again, and saves that\narrive while a sync is waiting are folded into it.\n\nUnlike the Mindbody sync above, this can never fail the save. The\nenrollment is what people book; the listing is a copy of the page, so\na stale one is worth less than a lost edit. Outcomes go to the log as\n`eventbrite_sync`, never to this response.\n\n**The Ora event follows the same way**, when `publish_to_ora` is set:\nthe post is sent to Ora's create again, which is an update when the\nevent exists (see `POST /posts/{id}/ora`). Logged as `ora_sync`.\n\nTicket PRICES are never changed, only quantities. An Eventbrite\nticket's price comes from a Mindbody service, and that picker is\nlocked once a post is published.\n**Editing a published post can rewrite its AI copy.** If the `title`,\n`description_html`, `start_date` or `end_date` actually changes value,\n`intro` and `short_description` are regenerated after this call\nanswers — same detached path as publish, same caveat that a 200 does\nnot mean they have landed. Any other edit leaves them alone, so moving\na price or a room does not spend a model call.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Any writable field. post_type is not one — its child rows describe the type it was created as.\n"}}}},"responses":{"200":{"description":"Item envelope with the updated post"},"400":{"description":"An unknown field","a bad value":null,"or a body that named nothing":null},"403":{"description":"The key is missing the content:write scope"},"404":{"description":"No post with that id","or it is soft-deleted":null}}},"delete":{"tags":["content"],"summary":"Soft-delete a post","description":"Sets `deleted_at` and leaves everything else — the row, its prices, its\nteachers, its slug history — exactly where it was. Scope:\n`content:write`.\n\nSoft rather than hard, for the reason db/061 gave the column in the\nfirst place: the Portal's own delete is `is_removed = 1`, 2,284 of its\n4,737 events are in that state, and they are still referenced. A post is\nalso what a published page, an Eventbrite event and a Mindbody\nenrollment all point at, so removing the row would strand three other\nsystems.\n\n**It takes the Eventbrite listing down too - unless somebody bought a\nticket.** Orders are counted first: none and the event is deleted and\n`eventbrite_id` cleared; any and the listing is LEFT ALONE. The\nresponse's `eventbrite` object says which, as `action`: `none`,\n`deleted`, `kept`, `unknown` (Eventbrite would not say, so it was left\nalone), `failed` or `not_configured`, with an `orders` count when one\nwas established.\n\nThe Portal deletes the listing blind and ignores the answer, so an\nevent with orders - which Eventbrite refuses to delete - silently\nstays listed there.\n\n**It takes the Ora event off Ora too - UNPUBLISHED, not cancelled**, a\ndeleted post being usually a mistake or a duplicate rather than an\nevent called off - and, like the listing, leaves it as it is when\ntickets are sold (`GET /events/{id}/ticket-types`, `quantity_sold`).\nThe response's `ora` object says which, as `action`: `none`,\n`unpublished`, `kept` (with `sold`), `unknown` (Ora would not say, so\nit was left as it is), `failed` (Ora refused - a cancelled, completed\nor archived event is a 409 - or did not answer) or `not_configured`,\nwith `event_url` and Ora's `detail`.\n\n**Mindbody is never touched.** A published post's enrollment stays\nlive and bookable, because it can have people on it who have paid and\nno tidy-up should destroy that. The response carries a `mindbody`\nobject with the `class_schedule_id` and a deep link into the back\noffice, so whoever deleted the post can finish the job. The Portal\nemails info@ instead, which reaches somebody who may not be them.\n\nAfter it answers, Datastream is asked to refresh its copy of that\nenrollment (`POST /v1/sync/enrollment`). Usually nothing changes,\nsince Mindbody still has it; when it was deleted in Mindbody first,\nthat refresh takes its upcoming dates off `/v1/schedule`. For a post\nthat was ever published, the Flow website is told too\n(`rally-events-refresh.php`), so the event's page and its card on the\nrally-events list go now rather than when Cloudflare's copy expires.\n\nThe LISTS filter `deleted_at IS NULL` by default, so a deleted post\nleaves them and leaves the editor. A direct `GET\n/v1/content/posts/{post_id}` still returns it, deliberately and by its\nown note — a lookup by id is a question about one specific post, and\n404ing a retired one would remove any way to inspect it. `deleted_at` is\non the response, so a caller that must not render retired content checks\nit, the same way it already checks `status`.\n\nRecovering one means clearing the column with owner\ncredentials — undelete is not an endpoint, because nobody has asked for\nthat workflow and guessing at it would be inventing one.\n\nDeleting an already-deleted post answers 404, the same as an id that\nnever existed.\n\nLike an update, this clears `legacy_source`: a post the loader wrote and\nsomebody then deleted here is no longer a pure copy of the Portal's, and\nthe next re-export must stop rather than quietly bring it back.\n","responses":{"200":{"description":"Item envelope with the post id","slug":null,"status and deleted_at":null},"403":{"description":"The key is missing the content:write scope"},"404":{"description":"No post with that id","or it was already deleted":null}}}},"/v1/content/faq-templates":{"get":{"tags":["content"],"summary":"The FAQ question bank","description":"What the studio offers an author writing any post of a type — 150\nquestions across retreats and teacher trainings, each with its\nanswer. Scope: `content:read`.\n\nThe Publisher puts these in a native datalist on its FAQ question box\nand fills the answer in when one is picked (`getFaqsAnswer()`). This\nis the same list, so both editors suggest the same things during\ncutover.\n\n**Not `post_block kind=faq`**, which is what one post actually says.\n`db/062` drew that line and the content loader encoded it as\n`FAQ_MODE=skip`: copying the type-level set onto 5,384 posts would\ninvent content nobody wrote there. `db/083` is where the type-level\nset lives.\n\n`post_type` is optional — omitted returns the whole bank, which is\nwhat a client caching it once for an editor session wants. Ordered by\nquestion, so the list does not reshuffle between two page loads.\n\nLoaded by `scripts/load-publisher-faqs.ts`\n(`ops.yml -f action=sync -f sync_job=publisher:faqs`), which upserts\nand prunes only the rows that came from the Portal.\n","parameters":[{"name":"post_type","in":"query","schema":{"type":"string","enum":["event","retreat","training"]},"description":"Unset returns every type. There are no event questions today."},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of questions with their answers"},"400":{"description":"An unknown post_type or an unknown parameter"}}}},"/v1/content/custom-locations":{"get":{"tags":["content"],"summary":"Reusable venues for events held outside a studio","description":"The Publisher's \"Saved locations\" list, ported from\n`flow_cms_custom_locations` (db/076). Scope: `content:read`.\n\nThis is the LIST an editor picks from, not the source of a published\npage's map. A post carries its own `address`, `map_embed_code` and\n`map_share_link` (db/063), and those are what the page renders. This\nexists so the tenth event at one park does not need someone to paste\nan embed URL for the tenth time.\n\n`map_embed_code` is an iframe `src` (a `/maps/embed?pb=…` URL), not a\nshare link — the two are not interchangeable and neither can be\nderived from the other.\n\n`eventbrite_venue_id` is the difference between a venue an event can\npublish to Eventbrite from and one it cannot. The Portal creates the\nvenue on first save and caches the id here; nothing in this platform\ncreates one yet, so treat null as \"not yet\", never as \"no\".\n\n`ora_venue_id` is the location's venue on Ora (db/094), made when the\nlocation is saved or by `scripts/ora-venues.ts`. Null means an Ora\nevent there carries just the location's name.\n\nSoft-deleted venues are excluded by default, matching the Portal's own\n`WHERE is_removed = 0`.\n","parameters":[{"name":"include_deleted","in":"query","schema":{"type":"string","enum":["true","false"]},"description":"Default false"},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of custom locations"}}},"post":{"tags":["content"],"summary":"Save a venue (create, or update the one with that name)","description":"A port of the Portal's saveCustomLocation(), which is an UPSERT KEYED\nON THE NAME, not a create. Scope: `content:write`.\n\n201 when the name was not there, 200 when it was. The name is\ncompared case-insensitively, because the Portal's MySQL `=` is and a\nPostgres `=` is not — without the fold \"Bell District\" would become a\nsecond venue here and stay one there.\n\nSaving a name that was soft-deleted REVIVES that row, keeping its id\nand its cached `eventbrite_venue_id`. That is the Portal's\n`is_removed = 0`, and it is why deleting is never a row delete.\n\n`eventbrite_venue_id` is NOT accepted from the caller. It is\nEventbrite's id, and a client able to set it could point a venue at\nsomebody else's.\n\n**`eventbrite_action` on the response** says whether an Eventbrite\nvenue was due, on the Portal's rule — new location, or the map changed:\n\n  * `none` — nothing was due: the map did not change, or there is no\n    map to resolve an address from.\n  * `needed` — a venue was due, and creating it was attempted.\n\n**`eventbrite_outcome`** then says what came of the attempt, and is\nnull when none was made:\n\n  * `created` — the venue exists and `eventbrite_venue_id` is set. This\n    location can publish to Eventbrite.\n  * `not_configured` — `EVENTBRITE_PRIVATE_KEY` /\n    `EVENTBRITE_ORGANIZATION_ID` are unset on this deployment.\n  * `no_address` — the map URL did not resolve to a postal address.\n    Eventbrite needs a street, city, region, postcode and a pin; a\n    share link or a blank map cannot supply one.\n  * `refused` — Eventbrite answered without an id. See\n    `eventbrite_detail`.\n  * `unreachable` — the request to Eventbrite or Google failed.\n\nA failure here never fails the save, and never clears an\n`eventbrite_venue_id` the location already had. A location that cannot\nreach Eventbrite is still worth having in the list, and losing a\nworking venue id to a timeout during a map edit would be worse than not\nrefreshing it.\n\nNote there is no way to DELETE an Eventbrite venue through their API\n(`DELETE /venues/{id}/` answers 405; it is UI-only). That is why a\nvenue is only created when one is genuinely due — the alternative is an\norganization slowly filling with duplicates that nothing can clean up.\n\n**`ora_outcome`** is the same for the location's Ora venue, attempted\nwhen `ORA_API_KEY` is set, there is a map, and either an Eventbrite\nvenue was due (new location, or the map changed) or the location has\nno `ora_venue_id` yet. Null when nothing was attempted; otherwise:\n\n  * `created` / `updated` — the venue exists on Ora and `ora_venue_id`\n    is set. Ora dedupes on the key `flow-custom:<id>`, so a changed\n    map moves the same venue rather than making another.\n  * `no_address` — the map did not resolve to an address. Nothing is\n    sent (Ora asks not to create a venue just to name a place); events\n    there carry the name.\n  * `refused` — Ora said no. See `ora_detail`.\n\nNon-fatal like the Eventbrite venue, and an existing `ora_venue_id` is\nnever cleared on failure.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["location_name"],"properties":{"location_name":{"type":"string","description":"The identity. Required, and blank is a 400."},"map_embed_code":{"type":"string","description":"The iframe src, a `/maps/embed?pb=…` URL. Not a share link — a share link will not render in an iframe and neither can be derived from the other.\n"},"map_share_link":{"type":"string","description":"The Share -> Copy link URL."}}}}}},"responses":{"200":{"description":"The venue that already had this name","updated":null},"201":{"description":"A new venue"},"400":{"description":"Blank location_name","a non-string field":null,"or an unknown field":null},"403":{"description":"The key is missing the content:write scope"}}}},"/v1/content/custom-locations/{id}":{"delete":{"tags":["content"],"summary":"Soft-delete a venue","description":"Sets `deleted_at`, the Portal's `is_removed = 1`. Scope:\n`content:write`.\n\nNever a row delete. A post names a venue by NAME and keeps its own\ncopy of the map, so dropping the row would throw away the Eventbrite\nvenue id and the Publisher id belonging to a place that published\nevents still say they were held at.\n\nDeleting an already-deleted venue is a 200, not an error, and the\noriginal `deleted_at` is kept — a double-submit cannot rewrite when\nit happened. Saving the same name again revives this row.\n","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The venue","now carrying deleted_at":null},"400":{"description":"The id is not a uuid"},"404":{"description":"No venue with that id on this tenant"}}}},"/v1/content/ai/description":{"post":{"tags":["content"],"summary":"Write a post description from an author's rough bullets","description":"A port of the Publisher's `generate_ai_description`: an author pastes\nrough notes and a model writes the description. Scope:\n`content:write`.\n\nSame model and settings as the Publisher — **grok-4** at\n`max_tokens: 500`, `temperature: 0.7` — because the system prompt was\ntuned against them, and someone moving from the Publisher to Rally\nshould get the same kind of writing back. That prompt says \"event\"\neven for a retreat or a training, exactly as the Publisher's does.\n\n**`content:write`, not `content:read`.** Nothing is written to\n`funnel_content`, but every call spends money on an external API, and\nthe read scope is handed out broadly (db/067 gave it to every key\nholding `contact:read`).\n\n**The response carries `html`, already escaped.** The Publisher\nconverts Markdown in the browser with a regex chain and no escaping,\nso anything the model emits that looks like a tag becomes live markup\nin the editor. Here the output is escaped first and only this service\nadds tags, so `<script>` from a model comes back as text. `markdown`\nis returned alongside for a client that formats it differently.\n\nErrors are never a 500 — each failure is a known state:\n\n  * `503` `not_configured` — `XAI_API_KEY` is unset on this deployment\n  * `503` `unreachable` — the request to xAI failed or timed out\n  * `502` `refused` — xAI answered with an error\n  * `502` `empty` — a 200 carrying no text (check `finish_reason`;\n    `length` means the 500-token ceiling truncated it)\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["bullets"],"properties":{"bullets":{"type":"string","maxLength":4000,"description":"The author's rough notes. Required and non-blank; capped at 4000 characters because this is billed per token.\n"}}}}}},"responses":{"200":{"description":"Item envelope with html, markdown and model"},"400":{"description":"Blank or over-long bullets","or an unknown field":null},"403":{"description":"The key is missing the content:write scope"},"502":{"description":"The model refused or returned nothing usable"},"503":{"description":"xAI is not configured here","or unreachable":null}}}},"/v1/content/images":{"get":{"tags":["content"],"summary":"Flow's image library","description":"The catalog behind the Publisher's image gallery and its \"Search\nFlow's image library\" box — a mirror of the Portal's `portal_dam`\n(db/078). Scope: `content:read`.\n\n**Metadata only.** Every picture is already served publicly by\nCloudflare Images, so each row carries a ready-made `url` at the\ngallery's width. A caller wanting another size swaps the trailing\nvariant — `/w=1200` for a hover preview, `/w=1000` for a hero, which\nis what the Portal does.\n\nThree ways to ask, and never more than one at a time:\n\n  * `keywords=gong,sound` — SCORED, best first. A match on `tags`\n    counts 3, on `description` 2, on `image_name` 1, which is the\n    Publisher's `searchImages()` weighting: a human typed the tags, a\n    model wrote the descriptions, and the file name is whatever the\n    camera called it. Rows scoring zero are excluded. This is what\n    \"find more images\" pages through, using the keywords\n    `/images/suggest` already returned.\n  * `q=cedar` — a plain \"does this word appear\" over those same three\n    columns, newest first. The library box.\n  * neither — the whole catalog, newest first.\n\nMatching is case-insensitive (`ILIKE`), because `portal_dam`'s MySQL\ncollation is and Postgres's is not — a faithful `LIKE` would quietly\nreturn less for the same words. `%` and `_` in a term are escaped, so\na stray wildcard cannot match the entire catalog.\n\n`orientation=horizontal` is how a caller asks for hero-shaped\npictures. Deliberately not the default here: this endpoint is the\nlibrary, and something wanting a portrait crop should not have to\nargue with it. `/images/suggest` defaults it the other way.\n\n**The mirrored rows change only when the loader runs**\n(`ops.yml -f action=sync -f sync_job=publisher:images`), not when\nsomebody uploads to the Portal's DAM. Pictures this platform accepted\nitself arrive through `POST /v1/content/images` and are in the same\ncatalog; `origin` says which is which.\n","parameters":[{"name":"keywords","in":"query","schema":{"type":"string"},"description":"Comma-separated, or the param repeated. At most ten; each is lowercased, trimmed and de-duplicated. Mutually exclusive with q.\n"},{"name":"q","in":"query","schema":{"type":"string"},"description":"Plain substring match. Mutually exclusive with keywords."},{"name":"orientation","in":"query","schema":{"type":"string","enum":["horizontal","vertical","square"]},"description":"Unset means every orientation."},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of images; `score` is present only for a keyword search"},"400":{"description":"Both keywords and q","or an unknown orientation":null}}},"post":{"tags":["content"],"summary":"Put a picture in the library","description":"Stores one image under the uploads root and records it in\n`funnel_content.image_asset` (db/081). Scope: `content:write`.\n\nThe library was readable and unwritable until this existed, because\ndb/078 mirrors the Portal's `portal_dam` and the Portal has no INSERT\ninto that table anywhere in its tree. Editor controls were waiting on\nit: a hero could not accept a photograph, every \"Upload image\" slot\nsaid so and could not, and a picture xAI drew was a `data:` URL that\nlived only in the browser tab that asked for it.\n\n**The bytes go on disk, not into Cloudflare Images.** The mirrored DAM\nlives there and stays there; new uploads go where the Publisher has\nalways put them, which is also where 586 of 686 live post heroes\nalready point. The same Cloudflare transform resizes both.\n\n**The directory says what the file is** — `events/hero/`,\n`retreats/destination/`, `headshots/`. It is built from `role` and\n`post_type` against allowlists, never from a string a caller spells,\nso traversal is impossible by construction. A pair that does not name\na real place is a 400 and not a fallback. `post_type` is create-only,\nso a path derived from it can never go stale.\n\n**The body carries the image as a base64 `data:` URL**, not\n`multipart/form-data`. The editor already holds its pictures that way,\nand this service takes no multipart dependency. Base64 is 4 bytes per\n3, so the request limit is 16 MB for a 10 MB image, enforced on the\nDECODED bytes.\n\nJPEG, PNG, WebP and GIF. SVG is refused: it is a document, it can\ncarry script, and it has no pixel dimensions to read.\n\n`width`, `height` and `orientation` are read from the file's own\nheader bytes, because `orientation` is what `/images/suggest` filters\non. Bytes whose dimensions cannot be read are still stored, with those\ncolumns left null.\n\n`origin` is declared by the caller because only the caller knows — the\nsame bytes arrive the same way whether a person chose a file or kept a\ngenerated picture. `portal_dam` is refused; a client cannot claim to\nbe the mirror.\n\nReturns **201** and a single image in the shape `GET\n/v1/content/images` returns: `asset_id` is what a post stores, `url`\nis where to render it from.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["data_url","role"],"properties":{"data_url":{"type":"string","description":"The image, as data:image/jpeg;base64,... — 10 MB decoded, max."},"role":{"type":"string","enum":["hero","logo","gallery","destination","rooms","headshot","generated"],"description":"What the picture is. The second path segment, or the only one for headshot and generated."},"post_type":{"type":"string","enum":["event","retreat","training"],"description":"The first path segment. Required for hero, gallery, destination and rooms; ignored for headshot and generated."},"filename":{"type":"string","description":"What to call it in the library. NOT what it is called on disk — this service names that."},"description":{"type":"string","description":"A sentence about what is in the picture. Searched, and used as alt text."},"tags":{"type":"string","description":"Comma-separated, in the DAM vocabulary. Weighted highest by a keyword search."},"origin":{"type":"string","enum":["upload","ai_generated"],"description":"Where the bytes came from. Defaults to upload."},"post_id":{"type":"string","description":"What it was uploaded for. Goes in the filename, as the Publisher does."}}}}}},"responses":{"201":{"description":"Item envelope with the stored image"},"400":{"description":"Not a base64 data URL, a format this refuses, over 10 MB, a role and post_type that name no directory, or an unknown field"},"403":{"description":"The key does not carry content:write"},"503":{"description":"The uploads directory could not be written; `outcome` is unwritable"}}}},"/v1/content/images/suggest":{"get":{"tags":["content"],"summary":"Pictures for an event title","description":"The Publisher's `action=suggest`: an author types an event title and\nthe gallery fills. Scope: `content:write`.\n\nThe title goes to xAI, which answers with keywords **from the DAM's\nown tag vocabulary**, and those run the scored search above. That\nvocabulary is listed in the prompt on purpose — nobody tags a photo\n\"Full Moon Sound Bath\", they tag it \"gong\", \"sound\", \"meditation\", so\nwithout the list the model returns good English that matches nothing.\n\n**grok-4-fast-non-reasoning**, `temperature: 0.2`, `max_tokens: 50`,\n10s timeout — all the Publisher's. A small, cheap model because this\nruns on every pause in typing rather than on a button press, and a low\ntemperature because the same title should keep returning the same\npictures.\n\n**`keywords` comes back with the images**, and that is not decoration:\na caller holds them so \"find more\" can page the same search through\n`/v1/content/images?keywords=…` without paying for a second model\ncall.\n\n`orientation` defaults to `horizontal` here — a suggestion is for a\nhero, and the Portal hard-codes it. Pass `any` for every orientation.\n\n**`content:write`, not `content:read`**, matching `/ai/description`\nand for the same reason: it spends money on an external API on every\ncall, and the read scope is handed out broadly (db/067). Reading the\ncatalog is a read; asking a model about it is not.\n\nErrors are never a 500 — each failure is a known state:\n\n  * `503` `not_configured` — `XAI_API_KEY` is unset on this deployment\n  * `503` `unreachable` — the request to xAI failed or timed out\n  * `502` `refused` — xAI answered with an error\n  * `502` `empty` — no usable keywords in the reply\n","parameters":[{"name":"title","in":"query","required":true,"schema":{"type":"string","maxLength":300},"description":"The event title. Required and non-blank."},{"name":"orientation","in":"query","schema":{"type":"string","enum":["horizontal","vertical","square","any"]},"description":"Default horizontal."},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"Item envelope with title, keywords, total, images and model"},"400":{"description":"Blank or over-long title","or an unknown orientation":null},"403":{"description":"The key is missing the content:write scope"},"502":{"description":"The model refused or returned nothing usable"},"503":{"description":"xAI is not configured here","or unreachable":null}}}},"/v1/content/ai/images":{"post":{"tags":["content"],"summary":"Draw five pictures for an event title","description":"The Publisher's \"Generate with AI\", for an event that has no\nphotograph. Scope: `content:write`.\n\n**xAI, not Google.** The standalone `publisher-images.php` uses Gemini\nand it is easy to read that file and conclude this is a Google\nintegration; the one the editor actually calls is\n`grok-imagine-image`. That is why this needs no new secret —\n`XAI_API_KEY` is already here for `/ai/description`.\n\nFive looks in one call, so an author gets a choice rather than one\nimage to take or leave. Model, styles and prompt template are the\nPublisher's verbatim: 16:9 because the result is a hero banner, a\nsingular minimal subject because a busy image loses to the title text\nlaid over it, and \"no text or words\" because a generated sign reading\nFLOW YOGE is the commonest reason one of these is unusable.\n\n**Some pictures are drawn and then blocked by xAI's own content\nfilter** — `imagine:content-moderated`, a 400, and billed. Measured at\none in five for an ordinary yoga event title, so it is routine rather\nthan exceptional; the response carries `moderated` so a caller that\nasked for five and got four can say why. If EVERY picture is blocked\nthe answer is a 502 `refused` rather than a 503, because that is about\nthe title's wording and not about the service being down.\n\n**Partial success is success.** Five independent calls to a generative\nAPI will sometimes not all land, and four pictures is a useful answer\nwhere an error is not. Only an empty set is a failure. Measured against\nthe live API, one generation is ~7.5s for a 169 KB JPEG; the five run\nconcurrently.\n\n**The pictures come back inline, as `data:` URLs, and that is not a\nstorage decision.** The Portal writes the bytes into its own web root\nand hands back a relative path; this service has no such directory and\nshould not grow one. Flow's images live in Cloudflare Images, which is\nwhere a generated one belongs — but that upload needs an account id and\nan Images-scoped token this deployment does not have, and there is no\nexisting Cloudflare upload anywhere in the Portal to port, so writing\none blind would be untested code on a paid path. `storage` on the\nresponse says which it is: `inline` today, and a delivery URL when\nthose two values land. Nothing else about the shape changes.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title"],"properties":{"title":{"type":"string","maxLength":300,"description":"The event title. Required and non-blank."},"count":{"type":"integer","minimum":1,"maximum":5,"description":"How many styles to draw. Defaults to all five; lower it to spend less, since every one is billed.\n"}}}}}},"responses":{"200":{"description":"Item envelope with title, model, storage and images (url, style)"},"400":{"description":"Blank or over-long title","a bad count":null,"or an unknown field":null},"403":{"description":"The key is missing the content:write scope"},"502":{"description":"The model refused or returned nothing usable"},"503":{"description":"xAI is not configured here","or none of the requests returned a picture":null}}}},"/v1/hubspot/ctas":{"get":{"tags":["hubspot"],"summary":"HubSpot CTAs (static UI export)","description":"Not available on a public API we can cron. Loaded from a one-shot parse\nof the HubSpot CTAs listing (`scripts/load-hubspot-ctas.ts`).\n","parameters":[{"name":"q","in":"query","schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string"},"description":"Published or Draft"},{"name":"type","in":"query","schema":{"type":"string"},"description":"Pop-up, Banner, Embedded, …"},{"name":"published","in":"query","schema":{"type":"string","enum":["true","false"]}},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of CTAs"}}}},"/v1/hubspot/ctas/{cta_id}":{"get":{"tags":["hubspot"],"summary":"One CTA","description":"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.\n","parameters":[{"name":"cta_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"CTA detail"},"404":{"description":"Unknown CTA"}}}},"/v1/hubspot/sms-campaigns":{"get":{"tags":["hubspot"],"summary":"HubSpot Marketing SMS campaigns","description":"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.\n","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Matches name or hs_id"},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of SMS campaigns"}}}},"/v1/hubspot/sms-campaigns/{sms_campaign_id}":{"get":{"tags":["hubspot"],"summary":"One Marketing SMS campaign","description":"Full record for one campaign, including the raw exported document.","parameters":[{"name":"sms_campaign_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Campaign detail"},"404":{"description":"Unknown campaign"}}}},"/v1/cloudtalk/calls":{"get":{"tags":["cloudtalk"],"summary":"CloudTalk call history","description":"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.\n\nEach 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.\n\nVoicemails 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.\n\n`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.\n","parameters":[{"name":"q","in":"query","schema":{"type":"string"},"description":"Matches the other party's number or contact name"},{"name":"type","in":"query","schema":{"type":"string","enum":["incoming","outgoing"]}},{"name":"status","in":"query","schema":{"type":"string","enum":["answered","missed","voicemail"]}},{"name":"location","in":"query","schema":{"type":"string"},"description":"Studio line label, e.g. \"Cedar Park\""},{"name":"date_from","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"date_to","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"contact_id","in":"query","schema":{"type":"string"},"description":"CloudTalk contact id"},{"name":"sentiment","in":"query","schema":{"type":"string","enum":["positive","neutral","negative"]},"description":"Conversation Intelligence sentiment"},{"name":"ai_call_type","in":"query","schema":{"type":"string","enum":["support","sales","other"]},"description":"Conversation Intelligence call classification"},{"name":"min_score","in":"query","schema":{"type":"integer"},"description":"Minimum overall call score (0–100)"},{"name":"analysed","in":"query","schema":{"type":"boolean"},"description":"true = only calls Conversation Intelligence analysed","false = only those it did not":null},{"name":"said","in":"query","schema":{"type":"string"},"description":"Substring match against the call transcript — what was actually said"},{"name":"has_transcript","in":"query","schema":{"type":"boolean"},"description":"Only calls that do (or do not) have a transcript"},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of calls"}}}},"/v1/cloudtalk/calls/{call_id}":{"get":{"tags":["cloudtalk"],"summary":"One call","description":"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`.\n\nThe 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`.\n","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Call detail"},"404":{"description":"Unknown call"}}}},"/v1/cloudtalk/calls/{call_id}/transcript":{"get":{"tags":["cloudtalk"],"summary":"One call's transcript","description":"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.\n\nRead 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).\n","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Transcript","summary and smart notes":null},"404":{"description":"Unknown call","or the call has no transcript":null}}}},"/v1/cloudtalk/calls/{call_id}/recording":{"get":{"tags":["cloudtalk"],"summary":"Stream a call's recording audio","description":"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.\n","parameters":[{"name":"call_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Raw recording audio (audio/wav)"},"404":{"description":"Unknown call","or call has no recording":null},"503":{"description":"CloudTalk credentials not configured","or upstream fetch failed":null}}}}}}