{"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, 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, 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"}}}},"/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/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}}],"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":"HubSpot Conversations threads (chat, email, SMS channel, etc.). Use `channel_id` / `channel_ids` for multi-select. Full-text `q` searches subject and message bodies.\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":"q","in":"query","schema":{"type":"string"},"description":"Search subject / message body"},{"$ref":"#/components/parameters/limit"},{"$ref":"#/components/parameters/offset"}],"responses":{"200":{"description":"List envelope of threads"},"403":{"description":"Missing `contact:read`"}}}},"/v1/conversations/{thread_id}":{"get":{"tags":["conversations"],"summary":"One conversation thread","description":"HubSpot Conversations metadata for one thread — status (open/closed), channel (live chat = 1000, email = 1002, SMS = 1009, etc.), assigned agent. Message bodies are the separate `/messages` sub-resource.\n","parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Item envelope of thread"},"404":{"description":"Unknown thread"}}}},"/v1/conversations/{thread_id}/messages":{"get":{"tags":["conversations"],"summary":"Messages in a thread (chronological)","description":"Every message in the thread, oldest first — type=MESSAGE only (system/bot events are filtered out). An empty text_body usually means an attachment, not a blank message.\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 messages"},"404":{"description":"Unknown thread"}}}},"/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"},{"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/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","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"},{"$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 — including the raw synced document.\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}/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}}}}}}