API reference
Every endpoint the API publishes, how to authenticate and the scope each call needs.
HeyCMO is an invitation-only, $0 operational Alpha for Stripe and Resend recovery emails. A person approves every message before it is sent. The guides also describe implementation capabilities outside the Alpha. Their presence here does not make them available in the pilot. Start with the Alpha checklist. Help: alpha@heycmo.ai.
The HeyCMO API, version 0.9.0, at https://agent-api.heycmo.ai. Every call is HTTPS with a JSON body. This page shows the API's contract: the same document the API serves at https://agent-api.heycmo.ai/openapi.json and this site at openapi.json, for your own tools.
Authentication and scopes
API keys and dashboard sessions
Send a bearer token in the Authorization header: Authorization: Bearer <token>. The token is one of:
- An API key (
hcmo_…) for the CLI, scripts and AI assistants. Create one in the dashboard, Settings → Team & API keys (orheycmo keys create), with the scopes it needs, from the ones you hold. Its secret is shown once. - A dashboard session: the token the dashboard holds for a person signed in with Clerk or WorkOS AuthKit.
Every endpoint says which scope it needs. A person holds their role's scopes: an organization admin all four, any other member read, approve and execute. A missing, expired or revoked token answers 401; a token without the scope answers 403. After 20 failed tokens from one address in 10 minutes, a further failed one answers 429; a valid token is still served. Every answer to a valid token names its workspace in the X-HeyCMO-Tenant-Id header; send that header with a request and it is refused with 403 (cross_tenant), before anything runs, if the token now belongs to another workspace. Public endpoints need no token, and the webhooks are signed by the provider that calls them instead.
| Scope | What it allows |
|---|---|
read | See the workspace: the inbox, loops and their runs, metrics, the audit trail, connections and settings. Changes nothing. |
approve | Decide: approve, edit or decline what waits in the inbox, acknowledge a brief, approve strategy sections, and get approval notices on a phone or in Slack. |
execute | Start work: run a loop or a playbook, draft, stage a change or a post for approval, undo an applied change, send data in, and turn the kill switch on. |
admin | Change the workspace: connect accounts and store secrets, set the sender, caps, loops and packs, manage API keys, request go-live, export or delete the workspace, and turn the kill switch off. |
OAuth for AI assistants (MCP)
An AI assistant connecting to the MCP endpoint (POST /v1/mcp) can sign in with OAuth instead of an API key, once HeyCMO has turned MCP OAuth on for the deployment: the Protected Resource Metadata at /.well-known/oauth-protected-resource/v1/mcp names the authorization server. The access token works on the MCP endpoint only, acts as the person who connected the assistant, and is narrowed to the scopes granted to it (below); no OAuth scope grants admin. Approving an inbox item needs a person in the dashboard: through an OAuth token, as through an API key, the assistant gets a link to the item instead.
| Scope | What it allows |
|---|---|
heycmo:read | The read scope: the MCP tools that list and read. |
heycmo:approve | The approve scope: decline an inbox item (approving one still needs a person in the dashboard). |
heycmo:execute | The execute scope: plan a campaign or start a playbook. |
Endpoints
315 endpoints in 61 groups. Each says who may call it.
Discovery and health
GET /health
Liveness
No key: anyone can call it.
Responses
200{ok, service, commit, builtAt, wave}: commit is the full SHA this build was made from and builtAt its build time (null for a local run); wave is a fixed legacy value
GET /openapi.json
This document
No key: anyone can call it.
Responses
200OpenAPI 3.1
GET /.well-known/agent-card.json
A2A agent card (A2A 0.3.0, discovery only): its skills are the MCP tools/list, and a required extension names the MCP endpoint; there is no A2A task endpoint
No key: anyone can call it.
Responses
200{protocolVersion, name, description, url, capabilities: {streaming: false, extensions: [{uri, required, params: {url, protocolVersion}}]}, securitySchemes, security, skills}
GET /.well-known/agent.json
The same A2A agent card at its pre-0.3.0 path, which A2A clients still probe (W5-D4)
No key: anyone can call it.
Responses
200the document /.well-known/agent-card.json serves
GET /.well-known/mcp.json
MCP Registry server.json for this deployment (schema https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json, W5-D4): the remote MCP endpoint and how to authenticate to it
No key: anyone can call it.
Responses
200{$schema, name, title, description, version, websiteUrl, remotes: [{type: streamable-http, url, headers: [{name: Authorization, isRequired, isSecret, value, variables}]}]}
GET /.well-known/oauth-protected-resource
RFC 9728 Protected Resource Metadata for the MCP endpoint (root well-known URL; E2.6)
No key: anyone can call it.
Responses
200{resource, authorization_servers, scopes_supported, bearer_methods_supported}404mcp_oauth_not_configured (HEYCMO_MCP_OAUTH_ISSUER unset)
GET /.well-known/oauth-protected-resource/v1/mcp
RFC 9728 Protected Resource Metadata for the MCP resource (path-specific; the URL every MCP 401 names)
No key: anyone can call it.
Responses
200{resource, authorization_servers, scopes_supported, bearer_methods_supported}404mcp_oauth_not_configured
GET /.well-known/oauth-protected-resource/v1/cli
RFC 9728 Protected Resource Metadata for the CLI resource that heycmo login signs in to (E5, #383)
No key: anyone can call it.
Responses
200{resource, authorization_servers, scopes_supported, bearer_methods_supported, resource_name}404cli_oauth_not_configured (MCP OAuth is off)
GET /oauth/heycmo-cli.json
The CLI's OAuth Client ID Metadata Document: a public client with loopback redirects (E5)
No key: anyone can call it.
Responses
200{client_id, client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope}404cli_oauth_not_configured
GET /cli/login
What heycmo login needs: the CLI resource, the authorization server, the client id and the scopes (E5)
No key: anyone can call it.
Responses
200{resource, authorizationServer, clientId, scopes}404cli_oauth_not_configured
GET /.well-known/openai-apps-challenge
ChatGPT app domain verification (W3-3): the token OpenAI issued, as plain text, from HEYCMO_OPENAI_APPS_DOMAIN_VERIFICATION
No key: anyone can call it.
Responses
200the verification token (text/plain, no-store)404no token configured
GET /
W3-7: a hosted landing page on a tenant's own hostname, served only to requests the variant edge forwards with its shared secret (x-heycmo-edge). The visitor's variant, a first-party cookie (__Host-hcv) for a new visitor, a strict CSP; the control and no cookie for Global Privacy Control or Do Not Track. Elsewhere, for a restricted workspace, or while the operator's landing-pages switch is engaged: 404
No key: anyone can call it.
Responses
200text/html404nothing published on this hostname, taken down, or not through the edge429rate limited (the visitor's address)
POST /_hc/v/{test}
W3-7: a hosted page's exposure beacon (through the variant edge only), for the test that produced the page: enrols the visitor once while that test still serves and allocates; a beacon from a page of a test no longer served enrols nothing. Always 204
No key: anyone can call it.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
test | path | yes | string (uuid) |
Responses
204nothing to read404not a test reference413body too large
GET /_hc/c/{test}/{label}/{index}
W3-7: a hosted page's goal link (through the variant edge only), bound to the test that produced the page: while that test still serves, records the conversion for an enrolled visitor inside their window and redirects to its approved target; once it is taken down or replaced, 404 (never another test's target)
No key: anyone can call it.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
test | path | yes | string (uuid) | |
label | path | yes | string | |
index | path | yes | integer 0–19 |
Responses
302to the approved goal URL (no-store)404no such link, or its test is no longer served429rate limited (the visitor's address)
Activity
GET /v1/activity/sends
Emails HeyCMO sent or rehearsed after an approval, newest run first (ordered by when they ran, before the limit); more says older sends exist
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
limit | query | no | integer 1–200 default 100 |
Responses
200{sends: [{id, loop, actionType, to, subject, at, dryRun}], limit, more}
GET /v1/activity/week
What HeyCMO did in the last 7 days, for the home's "This week" (B6): loop runs, items staged, decisions, emails sent (the sends /v1/activity/sends lists in the window: live, rehearsed, not recorded), external writes applied and reversals, payments recovered per currency
Scope: read.
Responses
200{since, until, days, loops: {runs, loopsRun, runsWithFindings, failedRuns}, staged, decided: {approved, declined}, sends: {total, live, rehearsed, unrecorded}, writes: {applied, reversals}, recovered: [{currency, cents, count, emailDrivenCents}]}
Agents
GET /v1/agents
Agents and keys (admin): HeyCMO's MCP address, whether people sign in with an account (then no key approves), whether an AI assistant (MCP OAuth) and the terminal (heycmo login) can sign in here, the assistants and terminal sign-ins that reached this workspace (each OAuth client and member, first and last seen, most recent first), and what went out in the last seven days by who decided it. API keys stay at GET /v1/api-keys
Scope: admin.
Responses
200{ mcpUrl, signIn: { people, assistants, terminal }, connections: [{ id, kind: mcp|cli, clientId, person, firstSeenAt, lastSeenAt }], week: { from, to, shipped, byPeople, standing, other, edited } }403admin scope required
API keys
GET /v1/api-keys
List API keys (admin): id, label, scopes, status, createdAt, expiresAt, lastUsedAt, revokedAt, lastFour (the secret's last four characters; null for a key minted before they were kept), creator (who created it: {kind: person, email} for a member of this workspace, {kind: key, name} for a key, or null) — never the secret
Scope: admin.
Responses
200{keys}403admin scope required
POST /v1/api-keys
Create an API key (admin). The secret is in this response only
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
label | yes | string (at least 1 characters, up to 100 characters) | |
scopes | yes | array of one of "read", "approve", "execute", "admin" (at least 1) | |
expiresInDays | no | integer 1–3650 |
Responses
201{key, secret, note}400bad body403admin scope required, or a scope the caller does not hold (scope_escalation)429credential change rate limit
POST /v1/api-keys/{id}/rotate
Rotate an API key (admin): new secret, same label and scopes; the old key is revoked at once (no grace window)
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
expiresInDays | no | integer 1–3650 |
Responses
200{key, secret, rotated, note}404no such key in this tenant409key already revoked
DELETE /v1/api-keys/{id}
Revoke an API key (admin). Idempotent; refuses the last active admin key of a tenant with no Clerk organisation
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{revoked, alreadyRevoked, key}404no such key in this tenant409last_admin_credential
Approve
POST /v1/approve/{id}
Approve staged action (MCP approve_action dispatches here for a signed-in session only)
Approval is a person's decision. Where people sign in (Clerk or AuthKit), only a signed-in session approves; a machine key changes nothing and gets 403 {code: human_approval_required, approved: false, approveUrl} for a person to open in the dashboard inbox. Declining stays open to keys.
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
operationHash | no | string |
Responses
200executed or blocked400invalid approval body403scope, or human_approval_required with approveUrl (a machine key where people sign in)409stale preview: human_preview_required (no operationHash) or approval_stale_or_resolved (the item changed since it was shown); or an unmet approval prerequisite. Nothing is approved or executed
Audit
GET /v1/audit
Audit log (get_audit_log)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
limit | query | no | integer 1–200 default 50 | |
loop | query | no | string | |
action | query | no | string | |
tier | query | no | integer | |
decision | query | no | string | executed, staged, blocked, escalated, approved, declined…; several comma-separated match any of them. |
surface | query | no | string | dashboard, pwa, slack, mcp, cli, api, system… |
from | query | no | string (date-time) | Only entries written at or after this ISO time. |
to | query | no | string (date-time) | Only entries written before this ISO time. |
cursor | query | no | string (uuid) | An entry id from the page shown: the entries older than it, the next page back. |
todayFrom | query | no | string (date-time) | Where "today" starts for summary.today (the reader's local midnight); UTC midnight by default. |
by | query | no | string | Only entries done by these: api_key (an API key), mcp (an AI assistant), cli (a terminal sign-in), person (a person signed in); several comma-separated match any of them. |
notKey | query | no | string (uuid) | With by=api_key only: leave out the entries done by this API key (its id). |
Responses
200{items, count, summary?: {total, today, matching, since}}: entries newest first, with each row's prevHash and rowHash, andby, who did it: {kind: api_key|mcp|cli|person, person, clientId, key: {id, name, lastFour}}, or null for HeyCMO on its own, a notice from a connected tool, or an entry written before who was recorded.clientIdis the assistant's or the terminal's OAuth client;person(the address) andkeyare given to admin readers only. The summary (first page only, not with cursor) counts the whole trail, today's entries and the entries matching the filters.400tier is not an integer, a time is not an ISO date and time, cursor is not an entry id, by names an unknown kind, or notKey is not a key id or comes without by=api_key
GET /v1/audit/filters
Tenant-wide audit filter values
Scope: read.
Responses
200{loops, actions}
GET /v1/audit/verify
Recompute this tenant's audit hash chain and check it against its daily anchors (E2.4)
Scope: read.
Responses
200ok, state, first failing row (failures[]), purges, anchors
GET /v1/audit/last-check
The daily check of this tenant's audit chain (Design v2 History: "Last checked automatically at 06:00"): the same walk as /v1/audit/verify, run after the daily anchor
Scope: read.
Responses
200{ checkedAt, state: verified|failed|unchained, entries, failure: { kind, seq } | null }, or { checkedAt: null } before the first check
GET /v1/audit/export
Audit evidence export: NDJSON header, pre-chain rows, chained rows with hashes (E2.4)
Scope: read.
Responses
200application/x-ndjson
Autonomy
GET /v1/autonomy
Graduated autonomy (E4.6): eligibility per loop/action type with reasons, grant state, plan entitlement, the signed threshold set, terms and history
Scope: read.
Responses
200{enabled, readOnly, plan, thresholdSet, bar, terms, policyDiagnostics, pairs, history, floor}; bar is the thresholds every pair is measured against {default, channels, actionTypes, incidents} (the signed set's rules, or the proposed defaults before one is signed); the payment-recovery send pair (failed-payment-dunning, send) also carries touchPlan {reminderDay, urgentDay, finalDay, includeRetrying, preDunning, summary}: the touch plan its grant covers (issue #232); design v2: each pair's lastEdit {at, fields, subject: {before, after} | null, added, removed} | null, the latest edit of a decision its record counts (a card still waiting is left out) from the feedback ledger (scrubbed: the words the edit added and took out, at most 120 characters each)
GET /v1/autonomy/eligibility
Autonomy eligibility from real decisions (approval rate, edit rate, observation days, time to decide) and incidents, against the operator-signed thresholds
Scope: read.
Responses
200{evaluatedAt, thresholdSet, pairs: [{loop, actionType, stats, incidents, rule, eligible, reasons}]}
POST /v1/autonomy/request
Request autonomy for one eligible loop/action type (admin; OS or Alpha plan). The platform operator approves
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
loop | yes | string | |
actionType | yes | string | |
acknowledgeTerms | yes | must be true | |
termsVersion | yes | string | |
reason | no | string (up to 500 characters) |
Responses
200state requested400terms not acknowledged or bad body402plan does not include autonomy (Operator, trial, restricted)409not eligible, thresholds unsigned, autonomy disabled, or not requestable from the current state
POST /v1/autonomy/revoke
Withdraw a request or end a grant now (admin); idempotent. /v1/autonomy/demote is the same route
Scope: admin.
Responses
200{state: gated, changed}
Billing
GET /v1/billing/prices
HeyCMO platform tiers + Checkout availability
Scope: read.
Responses
200tiers + checkoutEnabled
GET /v1/billing/status
HeyCMO plan, billing status, period end, when a plan set to cancel ends, entitlements, usage vs allowance, model spend vs its ceiling, suspension, no-card trial
Scope: read.
Responses
200plan + effectivePlan + currentPeriodEnd + cancelAt (when a subscription set to cancel ends: Stripe cancel_at or cancel_at_period_end; null while it renews) + entitlements + usage + modelSpend (todayCents, weekCents, dailyCeilingCents, weeklyCeilingCents, dailySource, weeklySource) + trial (endsAt, daysLeft, ended, pausedAt, pausedDays: W4-D2, the trial clock stops while a go-live request waits for HeyCMO and the end moves on by the time it waited) + trialPausedDays (0 when not on a trial) + restriction; addons.geoDaily; B6: plans ([{plan, maxEnabledLoops, maxLoopStage, stageExemptLoops, allowance, overageBilled, autonomy, hostedLandingPages, geoDaily}]: each plan's gates as the entitlement functions enforce them)
POST /v1/billing/checkout
Create Stripe Checkout Session (Operator/OS; admin)
Scope: admin.
Responses
200url + sessionId; the same request again gets the same open session (one per tenant), another plan or return URL expires it in Stripe first400return URL not on the public site or dashboard origin409subscription_exists — manage it in the Customer Portal (a no-card trial is not a subscription: checkout converts it); checkout_completed — the open session was just paid and its subscription is about to apply503platform Stripe not configured
POST /v1/billing/portal
Create Stripe Customer Portal session {returnUrl?} (admin)
Scope: admin.
Responses
200url400return URL not on the public site or dashboard origin409no_billing_customer — start with checkout503platform Stripe not configured
Brand
POST /v1/brand/draft
Draft the brand kit (colours, fonts, voice) from the site and merge it into the stored kit (execute). A draft that adds nothing (no model, HEYCMO_DRAFTER=template, or nothing found) comes back with a warning
Scope: execute.
Request body (application/json, required)
The site to read: siteUrl, as POST /v1/onboarding/bootstrap names it, or url (accepted too; both given must name the same address, compared as URLs: case of scheme and host, default port, fragment and a trailing slash do not count)
| Field | Required | Type | Description |
|---|---|---|---|
siteUrl | no | string (uri) | |
url | no | string (uri) |
Responses
200{kit, draftSource: llm|template|template-fallback, warning?}: warning only when the draft added nothing to the kit400neither siteUrl nor url, or two different sites: {error} says which field to send
Briefs
GET /v1/briefs
The weekly briefs, newest first (read, at most 12): each with its status, when the weekly review wrote it, the week it covers (the seven days up to then, however much later it was approved), when it was sent (null until then), when it was last acknowledged, and how many actions went out that week (acknowledged reports, approved drafts and run-now requests are not counted)
Scope: read.
Responses
200{ briefs: [{ id, status, generatedAt, sentAt, week: { from, to }, acknowledgedAt, shipped }] }
GET /v1/briefs/{id}
One weekly brief with its week (read): its payload (the digest and the movers), what went out that week grouped by action type and job (how many a person edited first, how many a standing approval sent), and the week's runs (how many, how many completed with nothing to do, the last failed ones)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{ id, loop, status, generatedAt, sentAt, week, acknowledgedAt, payload, shipped: { total, edited, standing, groups }, checks: { runs, nothingFound, failed } }404brief not found
POST /v1/briefs/{id}/ack
Acknowledge a completed weekly brief idempotently
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200acknowledgement recorded409brief not final
Card update
GET /v1/card-update
W5-A1: a recovery email's 1-click card-update link (unauthenticated; HMAC-signed by HeyCMO, expires 60 days after drafting). Creates a Stripe Billing Portal payment_method_update session at the click (governed stripe.createBillingPortalSession, Tier 1; a session URL is short-lived, so none is created at drafting) and redirects to it; otherwise to the fallback the link was signed with (the invoice's hosted page, or the portal's login page). The session sets only the customer's default card, so a link whose subscription names a card of its own, or cannot be read, also goes to the fallback; so does one whose subscription no longer charges (cancelled, expired, ended or unpaid), and any link of a workspace that is not active (suspended, deleted or billing-restricted). A dry-run workspace creates nothing and says so
Called by the provider and signed with its secret; API keys are not accepted.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
p | query | yes | string | the signed payload (base64url) |
s | query | yes | string | its HMAC-SHA256 signature (base64url) |
Responses
200text/html: dry run (nothing created in Stripe), or no fallback (reply to the email)303to the Stripe Billing Portal session, or to the fallback (no-store, no referrer)400text/html: not a valid card-update link (forged, cut, or signed before SECRETS_MASTER_KEY was rotated); nothing in it is followed410text/html: the link expired and has no fallback429rate limited with no fallback: the client's address (60 opens a minute; text/plain for a link that does not verify, else the reply page), or the link's session budget (10 opens in 10 minutes, every open counted). A link that verifies and has a fallback is a 303 to it instead503text/html: the card update page failed after the link verified and the link has no fallback (reply to the email)
Connect
GET /v1/connect
Stripe/Resend connection state and this tenant's webhook URLs and events
Scope: read.
Responses
200{stripe, resend}
POST /v1/connect/stripe
Validate a restricted Stripe key, store it, and register the attribution webhook (signing secret stored); manual steps when the key cannot manage webhooks
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
restrictedKey | yes | string | |
replaceWebhook | no | boolean |
Responses
200{ok, mode, keyStored, webhook: {status: created|reused|manual, url, events, …}, webhookSecretStored}422key invalid or cannot read invoices — nothing stored502Stripe unreachable — nothing stored
POST /v1/connect/resend
Validate a Resend API key, store it, and register the event webhook (signing secret stored); manual steps for a sending-only key
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
apiKey | yes | string |
Responses
200{ok, access, keyStored, webhook: {status: created|reused|manual, url, events, …}, webhookSecretStored}422key invalid — nothing stored502Resend unreachable — nothing stored
POST /v1/connect/sendgrid
Validate a SendGrid API key (GET /v3/scopes: mail.send required), store it, and set up the signed Event Webhook (verification key stored); manual steps when the key cannot manage webhooks
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
apiKey | yes | string | |
region | no | one of "global", "eu" | |
switchProvider | no | boolean |
Responses
200{ok, keyStored, region, webhook: {status: created|reused|manual, url, events, …}, webhookSecretStored (events can arrive: key stored, webhook enabled), legacyTemplate (on|off|null: marketing waits unless off), sender, emailProvider, switched, warnings}409email_provider_switch_required — another connected provider sends every email; nothing changed. sendgrid_key_replaced — another SendGrid key was stored while this connect ran; nothing more this key showed was kept422key invalid or without mail.send — nothing stored502SendGrid unreachable — nothing stored
GET /v1/connect/esp
Which ESPs are connected (Customer.io, Loops, Klaviyo), the esp write-channel posture and the attestation version; no provider call
Scope: read.
Responses
200{providers: [{provider, connected, credentialSource, writes, region?}], writeChannel, attestationVersion}
POST /v1/connect/customerio
Validate a Customer.io App API key (GET /v1/workspaces; both regions probed when none is given) and store it
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
appApiKey | yes | string | |
region | no | one of "us", "eu" |
Responses
200{ok, region, workspace, keyStored}422key invalid — nothing stored502Customer.io unreachable — nothing stored
POST /v1/connect/loops
Validate a Loops API key (GET /api/v1/api-key) and store it
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
apiKey | yes | string |
Responses
200{ok, team, keyStored}422key invalid — nothing stored502Loops unreachable — nothing stored
POST /v1/connect/klaviyo
Validate a Klaviyo private API key (accounts:read, metrics:read) and store it; Klaviyo is read-only
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
privateKey | yes | string |
Responses
200{ok, account, keyStored, writes: none}422key invalid or missing a scope — nothing stored502Klaviyo unreachable — nothing stored
GET /v1/connect/reviews
Which review sources are connected (G2 read only, App Store Connect, Google Play), their app/product ids and the reviews write-channel posture; no provider call
Scope: read.
Responses
200{providers: [{provider, label, connected, target, replies}], writeChannel, deferred}
POST /v1/connect/g2
Validate a G2 API token against the product (GET /api/v1/products/{id}) and store it; G2 is read only
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
token | yes | string | |
productId | yes | string (uuid) |
Responses
200{ok, product, productId, keyStored}422token invalid, no access to the product, or product not found — nothing stored502G2 unreachable — nothing stored
POST /v1/connect/appstoreconnect
Validate an App Store Connect API key (ES256 JWT; GET /v1/apps/{id} and a customer-review read) and store it
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
issuerId | yes | string (uuid) | |
keyId | yes | string | |
privateKey | yes | string | the .p8 file contents (PKCS#8 PEM, EC P-256) |
appId | yes | string |
Responses
200{ok, app, appId, keyStored}422key invalid, its role cannot read reviews, or app not found — nothing stored502App Store Connect unreachable — nothing stored
POST /v1/connect/googleplay
Validate a Google Play service account (token exchange at Google's endpoint, one reviews.list call) and store it
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
serviceAccount | yes | the service-account key file (JSON, as text or object) | |
packageName | yes | string |
Responses
200{ok, packageName, serviceAccount, keyStored}422not a service-account key, or it lacks the "Reply to reviews" permission — nothing stored502Google unreachable — nothing stored
POST /v1/connect/sanity
W2-8: check a Sanity connection (a query on the dataset and a dry-run write) and store it as the sanity secret for CMS publishing
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
projectId | yes | string | |
dataset | yes | string | |
token | yes | string | a project API token with the Editor role |
postType | no | string | document type of posts (default post) |
pageType | no | string | document type of landing pages; without it no landing pages are written |
bodyFormat | no | one of "portableText", "markdown", "html" | |
fields | no | object | field names: title, slug, body, excerpt, variant, variantOf (null leaves one unmapped) |
Responses
200{ok, platform, target, detail, pages, keyStored}400not a Sanity connection422token invalid, lacking a permission, or the project/dataset not found — nothing stored502Sanity unreachable — nothing stored
POST /v1/connect/contentful
W2-8: check a Contentful connection (content types, mapped field types, locale) and store it as the contentful secret for CMS publishing
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
spaceId | yes | string | |
environmentId | no | string | default master |
token | yes | string | a Content Management API token of a user who may edit and publish |
region | no | one of "us", "eu" | |
locale | no | string | default en-US |
postType | no | string | content type of posts (default blogPost) |
pageType | no | string | content type of landing pages; without it no landing pages are written |
bodyFormat | no | one of "richText", "markdown" | |
fields | no | object | field ids: title, slug, body, excerpt, variant, variantOf (null leaves one unmapped) |
Responses
200{ok, platform, target, detail, pages, keyStored}400not a Contentful connection422token invalid, a content type, field or locale missing or of the wrong type — nothing stored502Contentful unreachable — nothing stored
POST /v1/connect/git
W2-8: check a GitHub repository connection (repository, base branch, content directories) and store it as the git secret; pages are proposed as pull requests HeyCMO never merges
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
repo | yes | string | owner/name |
token | yes | string | a fine-grained token for this repository: Contents and Pull requests read and write |
baseBranch | no | string | default: the repository default branch |
postsDir | yes | string | |
pagesDir | no | string | landing pages; without it none are written |
format | no | one of "md", "mdx" | |
frontMatter | no | object | keys: title, slug, excerpt, date, draft, image, tags, variant, variantOf |
Responses
200{ok, platform, target, detail, pages, keyStored}400not a Git connection (a directory outside the repository, a hidden directory or a bad branch is refused)422token invalid or without access, branch or directory missing — nothing stored502GitHub unreachable — nothing stored
GET /v1/connect/product-analytics
Which product-analytics sources are connected (PostHog, Mixpanel, Amplitude), the one the daily collection reads and the event names; no provider call
Scope: read.
Responses
200{providers: [{provider, label, connected, credentialSource, target, region, oauthAvailable?}], collecting, provider, events: {active, activation, trial}, reads}
POST /v1/connect/posthog
Validate a PostHog personal API key against the project (GET /api/projects/{id}/ and a SELECT 1 HogQL query) and store it; read only
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
personalApiKey | yes | string | |
projectId | yes | string | |
region | no | one of "us", "eu" |
Responses
200{ok, provider, project, projectId, region, keyStored}422not a personal API key, unknown project, or missing project:read / query:read — nothing stored502PostHog unreachable — nothing stored
POST /v1/connect/mixpanel
Validate a Mixpanel service account against the project (a one-day segmentation) and store it; read only
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
serviceAccountUsername | yes | string | |
serviceAccountSecret | yes | string | |
projectId | yes | string | |
region | no | one of "us", "eu", "in" |
Responses
200{ok, provider, projectId, region, keyStored}422not a service account, unknown project, or no access to it — nothing stored502Mixpanel unreachable — nothing stored
POST /v1/connect/amplitude
Validate an Amplitude API key and secret key (one day of active users) and store them; read only
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
apiKey | yes | string | |
secretKey | yes | string | |
region | no | one of "us", "eu" |
Responses
200{ok, provider, region, keyStored}422the key pair is refused — nothing stored502Amplitude unreachable — nothing stored
POST /v1/connect/segment
Create (or rotate) the Segment destination's shared secret; returns it once with the per-tenant webhook URL (admin, audited)
Scope: admin.
Responses
200{ok, provider, webhookUrl, sharedSecret (shown once), rotated, setup}429too many credential changes
GET /v1/connect/github-release
The GitHub release webhook (W5-D2): whether its secret is stored, the URL to paste into GitHub, the last delivery, and the latest release launches (status, Marketing Factory run, cards staged)
Scope: read.
Responses
200{configured, webhookUrl, events, lastDeliveryAt, lastDeliveryEvent, launchesPerDay, launchesToday, launches: [{origin, repo, tagName, url, status, runId, stage, stagedCount, attempts, reason, startedAt, finishedAt}], blocked: [{origin, repo, tagName, url, block, reason, receivedAt}]}; status is started, awaiting_approval, completed, declined, bailed, failed, blocked or interrupted, and follows the run once a person decides it; blocked lists the releases a gate (kill switch, restriction, no plan, daily cap) kept from starting that have not launched since
POST /v1/connect/github-release
Create (or rotate) the GitHub release webhook's secret; returns it once with the per-tenant webhook URL and the setup steps; the old secret stops working at once (admin, audited without the secret)
Scope: admin.
Responses
200{ok, provider, webhookUrl, secret (shown once), rotated, contentType, events, setup}403read-only key429too many credential changes
Connections
GET /v1/connections
OAuth connection status per provider (ga4, gsc, googleads, hubspot; bing, webflow, postmaster, meta, linkedin, x, posthog, attio, pipedrive, microsoftads, canva and twenty when the operator configured their integrations): status, credential source (nango|secret|none), readiness, granted and missing scopes, chosen resource — never a token. crmProvider: the CRM account context reads (hubspot|attio|pipedrive|twenty|null)
Scope: read.
Responses
200{configured, crmProvider, providers}
POST /v1/connections/{provider}/session
Create a Nango Connect session for this tenant and one provider (admin); a reconnect session when a connection exists. The connection is usable once the Nango webhook reports it. One CRM per tenant (hubspot, attio, pipedrive, twenty — Twenty by an API key the tenant pastes into the Nango Connect page): connecting a CRM while another is connected needs {switchProvider: true}; the previous CRM connection is deleted once Nango confirms the new one
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
provider | path | yes | one of "ga4", "gsc", "googleads", "hubspot", "bing", "webflow", "postmaster", "meta", "linkedin", "x", "attio", "pipedrive", "canva", "microsoftads", "twenty" |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
switchProvider | no | boolean | CRMs only: confirm moving account context to this CRM |
Responses
201{provider, integrationId, mode: connect|reconnect, token, connectLink, expiresAt}404unknown_provider409crm_switch_required — another CRM is connected; {currentProvider, requestedProvider}, nothing changed429credential change rate limit502nango_unavailable — nothing changed503oauth_not_configured (NANGO_SECRET_KEY unset)
PATCH /v1/connections/{provider}
Choose the GA4 property, Search Console site, Google Ads customer, Meta resources or LinkedIn Page/ad account/own profile the connection reads and writes (admin). Meta: page:<id>, ig:<id> and/or adaccount:<id> joined by |, merged onto the current choice; LinkedIn (E4.2): {organizationId?, adAccountId?, personId?} (null clears one; personId — W2-2 — the connected member as a post author) or a picker resourceId, merged into its choice. X (W2-2) is refused: its account is the one X reports, recorded by the resources listing. Microsoft Advertising (W3-4): account:<id>,customer:<id>, an ad account and the customer it belongs to, as the resources listing offers them
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
provider | path | yes | one of "ga4", "gsc", "googleads", "bing", "meta", "linkedin", "x", "microsoftads" |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
resourceId | no | string | required except for linkedin |
organizationId | no | string or null | linkedin only |
adAccountId | no | string or null | linkedin only |
personId | no | string or null | linkedin only (W2-2): the connected member, urn:li:person:<id> or the id |
Responses
200{ok, provider, resourceId}400invalid_resource409not_connected422resource_not_applicable (hubspot, attio, pipedrive, twenty); resource_from_connection (x)
DELETE /v1/connections/{provider}
Disconnect: delete the Nango connection (admin). Idempotent
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
provider | path | yes | one of "ga4", "gsc", "googleads", "hubspot", "bing", "webflow", "postmaster", "meta", "linkedin", "x", "attio", "pipedrive", "microsoftads", "twenty" |
Responses
200{ok, provider, deleted, status: not_connected}502nango_unavailable — nothing changed503oauth_not_configured
GET /v1/connections/{provider}/resources
GA4 properties, Search Console sites, Google Ads customers, Meta Pages / linked Instagram accounts / ad accounts, LinkedIn Pages, ad accounts and the connected member's own profile (ids organization:<id> / sponsoredAccount:<id> / person:<id>), the connected X account (its user id, a billed X read), or the Microsoft Advertising accounts the connected user manages (ids account:<id>,customer:<id>) the connection can read; the only one (Meta, LinkedIn: of each kind) is chosen when none is — never the LinkedIn member, whom only the tenant chooses; the X account is always the one X reports
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
provider | path | yes | one of "ga4", "gsc", "googleads", "bing", "meta", "linkedin", "x", "microsoftads" |
Responses
200{provider, resources: [{id, label}], selected, grantedPermissions? (meta)}409not_connected502provider_unavailable
Content
POST /v1/content/drafts
"Draft this" on an SEO content brief, or "Draft refresh" (pageUrl) on a content refresh brief (#233, execute): T-01 writes the article (1,200–2,000 words) from the brief, or from the page as WordPress reports it, through the metered model client; the on-page checklist runs (title 30–60 characters, no H1 in the body, the search in the title and first paragraph, at least 2 links to the site, outside links only from the inputs, a title no other page has, the length), with one retry for a failed check; it is staged as a cms.publishPost draft (a new page) or a cms.updatePost edit (a page that exists, WordPress only). Nothing is published; a card already staged for the same source, CMS and page is answered without a model call, unless it ended without the live result (declined, failed or blocked: drafted again; only rehearsed: the same article on a fresh card, without a model call)
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sourceActionId | yes | string (uuid) | |
pageUrl | no | string (up to 2048 characters) | a content refresh brief: the page to refresh (one it lists) |
query | no | string (up to 300 characters) | an SEO content brief with several searches: the one to draft (default the first); each search has its own card |
platform | no | one of "wordpress", "ghost", "webflow", "sanity", "contentful", "git" | required when several CMSs are connected |
Responses
200a draft of this source for that CMS, page and search was already staged (alreadyStaged: true; its checks run again)201{stagedActionId, alreadyStaged: false, actionType: cms.publishPost | cms.updatePost, kind: new_page | optimize_page | refresh, sourceActionId, title, words, checks: [{id, ok, detail}], checksPassed, editorialItemId, channels, leftOut, posture}400invalid body, or the write refused the article at staging404not_found — source action not found (another workspace's too)409source_declined, cms_not_connected, cms_paused (the cms kill switch stopped the read of the page), unsupported_platform (editing a page needs WordPress), page_not_found, or no_model (no model provider is configured; nothing was staged)422not_draftable — not an SEO content brief or a content refresh brief, a refresh brief without one of its pages as pageUrl, a query the brief does not hold, or a brief with no page to rewrite; page_not_rewritable — the page holds what a whole-body rewrite could not keep (images, embeds, tables, forms, scripts, WordPress blocks other than text, shortcodes) or more text than the draft reads (16,000 characters); the message names it, nothing was staged
POST /v1/content/publish
Stage the publish of a CMS draft HeyCMO saved and that was applied live (#233, execute): one cms.publishPost with the same post and status published, its own approval (asked again, the same card; a fresh one once it was declined, failed, blocked or only rehearsed). Approving it publishes the post; the IndexNow submission of its address is then proposed as its own card
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
draftActionId | yes | string (uuid) |
Responses
200the publish of this draft was already staged201{stagedActionId, alreadyStaged: false, actionType, channels, target, preview, draftActionId, editorialItemId, posture}404not_found — draft not found409not_a_draft (not a CMS draft post HeyCMO saved) or not_applied (the draft is not in the CMS yet: approve it, with the cms channel live)
POST /v1/content/link-edits
Propose link edits from an internal-link report (#233, execute): for each page to link from (at most 3, WordPress), one sentence that names a target page gets a link to it — one cms.updatePost card per page, each its own approval. Pairs with no such sentence are listed, never guessed
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sourceActionId | yes | string (uuid) |
Responses
200every card was already staged201{staged: [{pageUrl, stagedActionId, alreadyStaged, edits}], unmatched: [{from, to, reason}], channels, posture}400invalid body, or the write refused an edit at staging404not_found — source action not found409cms_not_connected, cms_paused (the cms kill switch stopped the read of a page), unsupported_platform (WordPress only) or page_not_found422not_draftable (not an internal-link report, or nothing to link) or nothing_to_change
GET /v1/content/snapshot
The content snapshot the content loops read (#233): its source (ingest = a partner push, which stays until the partner pushes again; first_party = derived weekly from Search Console page clicks over 90 days against the 90 before, GA4 page views and the connected WordPress site's posts), when, from what (with notes on what was not read), when the next weekly derivation is due, and its busiest pages
Scope: read.
Responses
200{source, updatedAt, pages, derived: {at, windowDays, gsc, ga4, cms, notes} | null, nextRefreshAfter, topPages: [{url, title, traffic90d, trafficPrev90d, position, pageViews90d, pageViewsPrev90d, lastRefreshedAt, sources}], losing: [the pages content-decay counts as losing visitors (at least 25% down on the 90 days before), the worst first, at most 25: {…topPages fields, drop, refresh: {briefActionId, briefStatus, briefAt, draftActionId, draftStatus, draftRehearsed: the edit ran as a rehearsal and changed nothing} | null}], losingTotal, links: design v2, the newest internal-linking report (internal_link_draft) of the last 45 days unless declined: {actionId, loop, status, at, linkTo: [{url, title}] (pages that get few links), linkFrom: [{url, title}] (the busiest pages; the first 3 are the ones POST /v1/content/link-edits edits), broken (internal links it found broken), drafts: [the cms.updatePost link edits staged from it, the newest per page: {actionId, url, title, links, anchors: [{text, href}] (the words each link goes on, a phrase the page already has, and the page it points to), status, rehearsed}]} | null}
POST /v1/content/snapshot/refresh
Derive the content snapshot now from Search Console, GA4 and the CMS, whatever its age (execute); never over a partner push. A page seen for the first time since the previous derivation is proposed for IndexNow (one card, its own approval)
Scope: execute.
Responses
200{refresh: {status: derived, pages, updatedAt, derived, newUrls, indexNow} | {status: skipped, reason: partner_push | no_source | conflict, detail}, snapshot}
GET /v1/content/calendar
The editorial calendar (#233): one item per topic and week (Monday, UTC), from two weeks back (or from the week of from), with its channel, status (idea, briefed, drafted, published, dropped), the loop that first proposed it and every proposer since, and the brief, draft and publish cards. Items of earlier weeks that are not done come first: a brief or draft waiting on a person (briefed, drafted), and a published post or edit at a known address whose 56-day page readback is not in yet while the daily readback pass may still record it
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
weeks | query | no | integer 1–26 default 8 | |
from | query | no | string (date) | A calendar day (YYYY-MM-DD): the calendar starts at its week and runs weeks weeks; default two weeks back, running weeks + 2 |
since | query | no | string (date-time) | A date and time (the reader's month start): adds month: {since, published, dropped}, the topics published and dropped since, in any week |
Responses
200{from, weeks, items: [{id, weekStart, topic, angle, channel, status, sourceLoop, sourceActionId, proposedBy, draftActionId, draftActionType: cms.publishPost (a draft post) | cms.updatePost (an edit of a page) | null, publishActionId, url, outcome: {readbacks?: [{windowDays, changeDay, clicks, impressions, position, clicksBefore, impressionsBefore, positionBefore, readiness, outcome, label}], geoGapsClosed?: [{promptId, prompt, runId, periodKey}]} | null, updatedAt}]} — outcome: the page readbacks of the published post or edited page (Search Console, 14/28/56 days) and the GEO citation gaps that closed after it went live; directional, never causal400weeks out of range, from not a calendar day, or since not a date and time
PATCH /v1/content/calendar/{id}
Drop an editorial item still to write, or bring a dropped one back as an idea (execute)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
status | yes | one of "dropped", "idea" |
Responses
200{item}400invalid body404not found (another workspace's item too)409the item cannot take that status (a published item is never dropped; only a dropped item comes back)
Context
GET /v1/context/history
The product context's version history, newest first (read): each substantive write's version, the section it changed (null for the whole document), the reason, who proposed it (draft:<source>, api-key:<id>, loop:<id>, playbook:<id>) and who accepted it. Only the changelog is kept, not each version's body. limit 1-200, default 50. latest is each section's newest change (and the whole document's), however far back, so where a section came from does not depend on the limit
Scope: read.
Responses
200{ version, versions: [{ version, fromVersion, section, reason, proposedBy, acceptedBy, at }], latest: [same fields] }: version is the current one, null before the first context (when versions and latest are empty too); both lists newest first
POST /v1/context/draft
Draft the product context from the site (execute). A blank template (no model, or HEYCMO_DRAFTER=template) comes back with a warning; drafting again replaces an untouched template
Scope: execute.
Request body (application/json, required)
The site to read: siteUrl, as POST /v1/onboarding/bootstrap names it, or url (accepted too; both given must name the same address, compared as URLs: case of scheme and host, default port, fragment and a trailing slash do not count)
| Field | Required | Type | Description |
|---|---|---|---|
siteUrl | no | string (uri) | |
url | no | string (uri) |
Responses
200{version, bodyMd, summary, sections, draftSource: llm|template|template-fallback, warning?}: warning only for a template400neither siteUrl nor url, or two different sites: {error} says which field to send409a context that describes the product exists, or someone wrote in the blank template while HeyCMO drafted over it (nothing is overwritten): edit it with PUT /v1/context
Conversions
GET /v1/conversions
W3-1 conversion send-back: the settings, each destination's readiness (Google Ads through the Data Manager API, LinkedIn through the Conversions API; the tenant's Stripe key is required) and mapping, the Stripe side (key stored, the endpoint's events, when to reconnect), whether sends are rehearsed, 30-day counts and the latest ledger rows — never an address, hash or click id
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
limit | query | no | integer 1–200 |
Responses
200{dryRun, validateOnly, settings: {unknownConsent, destinations}, destinations: [{destination, label, ready, reason, account, mapped}], stripe: {keyStored, reason, events, note}, maxAttempts, stripeEvents, metadataKeys, counts30d, recent: [{id, destination, conversionType, status, dryRun, sourceObjectId, destinationRef, occurredAt, valueMinor, currency, identifierKind, adUserData, adPersonalization, consentSource, providerRequestId, reason, createdAt}], capture: {endpoint, liveModeOnly, liveModeNote, metadataKeys: {visitor, clickIds, utm, hosted}, cookies, touchRetentionDays, maxTouchesPerRequest, snippet, serverHelper}} (#235: first-party click capture)
PUT /v1/conversions/settings
Map a Google Ads conversion action and a LinkedIn conversion rule (ids as digit strings; null unmaps; fields not sent are kept) to each conversion type, and choose what an unknown ad_user_data consent does (admin, audited)
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
unknownConsent | no | one of "skip", "send" | |
destinations | no | object |
|
Responses
200{ok, settings, destinations}400malformed body or id
POST /v1/conversions/consent
Record one customer's consent from the tenant's consent tool (source api), before the Stripe event it should apply to: by Stripe customer id or e-mail address (kept only as tenant-scoped tokens of its plain and its Google form); a denial from any source holds
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
customerId | no | string | |
email | no | string (up to 320 characters) | |
adUserData | no | one of "granted", "denied", "unknown" | |
adPersonalization | no | one of "granted", "denied", "unknown" | |
tcfString | no | string (up to 8192 characters) or null |
Responses
200{ok, subject, source, adUserData, adPersonalization, tcfStringStored, updatedAt}400exactly one of customerId or email, and at least one consent field503consent by address needs the platform PII token key
POST /v1/conversions/click
#235 first-party click capture: the tenant's server sends the landings the capture snippet kept for one visitor (its random visitor id and up to 20 touches: when, the ad click ids and UTM tags, or the referring site). Kept under an HMAC token of the visitor id for 90 days; conversion send-back uses a captured Google or LinkedIn click id when a conversion's Stripe metadata names the visitor (heycmo_vid). Never logged or audited: the visitor id and click ids
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
visitorId | yes | string | |
touches | yes | array of object (at least 1, at most 20) |
|
Responses
200{ok, stored, duplicates, touches: [{source, medium, campaign, channelGroup}]} — the channel each touch resolves to; no id is returned400an unknown field, an invalid value, a time in the future or older than 90 days, or a touch with neither a click id, a UTM tag nor a referring site403read-only key413body larger than 16 KB503click capture needs the platform PII token key
Creative
GET /v1/creative/config
Creative studio (E4.4): settings, month-to-date generation usage against the budget, templates and sizes, the model allowlist (vetted, allowed, configured), and whether storage and C2PA signing are available (signer kind, trusted, and why not: a certificate without the C2PA claim-signing EKU, or outside its validity)
Scope: read.
Responses
200settings, usage, availability, templates, models
PUT /v1/creative/config
Set creative settings (admin): enabled, monthlyBudgetCents (at or below the platform budget; null = platform default)
Scope: admin.
Responses
200updated400invalid or unknown field, or above the platform budget
GET /v1/creative/assets
The creative gallery: signed draft assets with alt text, provenance, the ref an external-write operation carries, and a short-lived URL; design v2: usedIn [{actionId, loop, actionType, status, createdAt, rehearsed}], the cards whose payload references it (an assetId at any depth), made or edited since the oldest listed asset, newest first (at most 3), never their payload, and usedInCount, how many such cards in all
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
briefActionId | query | no | string (uuid) | |
limit | query | no | integer 1–200 |
Responses
200assets
GET /v1/creative/assets/{id}
One asset plus a live read-back: stored bytes re-hashed and the C2PA manifest re-validated, with its trust-list status (trustList)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200asset, verification404not found
POST /v1/creative/templates/{id}/preview
Design v2 Images: a live preview of a brand-kit template at one size (default the template's first) as the copy is typed: rendered like a draft, but unsigned, never stored or metered, and scaled to at most 640 px; at most 60 a minute per workspace
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sizeId | no | string | |
copy | yes | object |
|
Responses
200{image: data:image/webp;base64,…, templateId, sizeId, width, height} (width and height are the full size)400invalid_input: an unknown template or size, or copy over its limits403the studio is off429rate_limited (retry-after)
POST /v1/creative/assets/render
Render a brand-kit template (social-card, og-image, ad-image) at channel sizes — Satori + resvg + sharp, no AI. Each image is C2PA-signed and stored as a draft asset (governed tool creative.renderTemplate)
Scope: execute.
Responses
201assets400invalid template, size or copy403read-only key or studio off423creative kill switch engaged503asset storage or C2PA signing not configured
POST /v1/creative/assets/generate
Generate one image with an allowlisted model (vertex:gemini-3-pro-image, vertex:gemini-3.1-flash-image, openai:gpt-image-2) within the monthly budget; the brief is screened for injection; the image is C2PA-signed as AI-generated (governed tool creative.generateImage)
Scope: execute.
Responses
201asset400model not allowed or invalid input402monthly generation budget exceeded422prompt blocked or provider refused423creative kill switch engaged503provider, storage or signing not configured
POST /v1/creative/briefs/{actionId}/assets
Turn a staged creative brief (daily-creative-drop, playbook T-04/T-05) into draft assets: mode template | generate, itemIds?, model?, sizeId?, format?
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
actionId | path | yes | string (uuid) |
Responses
201assets207some assets made before a refusal400no creative brief, no matching items, or invalid body404not found
POST /v1/creative/remotion-spec
W5-D3: compile a release's grounded facts (a W5-D2 release launch run's, by factoryRunId, or release notes given here) and the Brand Kit into a validated Remotion composition spec (ProductLaunchChangelogV1: hook, feature bullets, at most one metric proof, call to action; 16:9 1920×1080, 9:16, 1:1; 30 fps) per aspect, under claim guardrails (every scene cites a release fact; a metric proof's metric, every number and any superlative must be stated by the facts it cites; links only to the release's pages), and sign each spec's storyboard as a draft asset whose C2PA manifest binds the spec's SHA-256 (com.heycmo.composition). HeyCMO renders no video: render the spec with your own Remotion project. Governed tool creative.composeLaunchVideo
Scope: execute.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
factoryRunId | no | string | a product_launch run a GitHub release started |
release | no | object |
|
aspects | no | array of one of "16:9", "9:16", "1:1" | default 16:9 and 9:16 |
assetIds | no | array of string (uuid) (at most 3) | signed studio images shown on feature scenes |
storyboard | no | boolean | default true |
dryRun | no | boolean |
Responses
200{release, compositions: [{aspect, specSha256, spec, remotion, captionsSrt, storyboard: null}], notes, rendered: false} (dry run, or storyboard: false)201{release, compositions: [{aspect, specSha256, spec, remotion, captionsSrt, storyboard, provenance}], notes, rendered: false}400invalid body: exactly one of factoryRunId and release; a release url that is not its repo's page for its tag403read-only key or studio off404no such run or studio asset409not a release launch run, no release facts, or a studio asset that does not verify422release notes a launch cannot cite (a draft, a pre-release, empty)423creative kill switch engaged503asset storage or C2PA signing not configured
GET /v1/creative/remotion-spec/{assetId}
W5-D3: a launch video storyboard's composition spec, re-verified: the stored bytes, the C2PA manifest and signer, and the spec's SHA-256 against the one the manifest signed. What a renderer fetches; nothing whose spec does not verify may be rendered
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
assetId | path | yes | string (uuid) |
Responses
200{aspect, specSha256, spec, remotion, captionsSrt, storyboard, provenance, rendered: false}404not found, or not a launch video storyboard409the storyboard or its spec no longer verifies (tampered, manifest or spec mismatch, untrusted signer)503asset storage could not be read
GET /v1/creative/canva
Canva brand templates (W3-5): the workspace's Canva connection (offered when the operator configured the Canva integration; status, readiness, missing scopes) and the latest renders
Scope: read.
Responses
200{connection, renders}
GET /v1/creative/canva/templates
The connected Canva user's brand templates that have autofill fields, newest first, and whether the Canva plan has Autofill (governed tool creative.listCanvaTemplates)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
query | query | no | string (up to 100 characters) | |
continuation | query | no | string (up to 500 characters) | |
limit | query | no | integer 1–50 |
Responses
200{templates, continuation, capabilities}403studio off, or the Canva plan lacks brand templates409Canva not offered or not connected423creative kill switch engaged429Canva's rate limit502Canva unavailable
GET /v1/creative/canva/templates/{templateId}
One brand template's autofill fields (name, type text|image|chart|sheet) and the mapping each uses: the workspace's own, or the default by name and type (governed tool creative.readCanvaTemplate)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
templateId | path | yes | string |
Responses
200{template, fields, stale}404not found409Canva not offered or not connected423creative kill switch engaged
PUT /v1/creative/canva/templates/{templateId}/mapping
Set the workspace's field mapping for one Canva brand template (admin): {fields: {<field name>: {source: headline|body|cta|eyebrow|objective|angle|brandName|none} | {source: 'text', text} | {source: 'asset', assetId?}}}, checked against the template's current fields; {} goes back to the defaults. Audited
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
templateId | path | yes | string |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
fields | yes | object |
Responses
200{ok, template, fields, stale}400malformed mapping, unknown field, or a source that cannot fill the field403read-only key or not admin409a studio asset that is not signed, or Canva not connected
POST /v1/creative/canva/renders
Autofill a Canva brand template from a staged creative brief (briefActionId, briefItemId?) or copy, with the workspace's signed studio images for image fields, export it (png | jpg) and sign the export into a draft asset (Canva's export as the c2pa.opened parent; AI-generated when an AI image was placed). Starts a run (202) that finishes in the background; dryRun: true answers the fill plan and writes nothing to Canva (governed tool creative.renderCanvaTemplate)
Scope: execute.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
brandTemplateId | yes | string | |
copy | no | object |
|
briefActionId | no | string (uuid) | |
briefItemId | no | string | |
images | no | object | image field name → studio asset id |
format | no | one of "png", "jpg" | |
title | no | string (up to 200 characters) | |
dryRun | no | boolean |
Responses
200{plan} (dry run)202{render} started400invalid body, or nothing to fill403read-only key, studio off, or the Canva plan lacks Autofill409Canva not connected, or a studio image that is not signed423creative kill switch engaged429three renders already running, or Canva's rate limit
GET /v1/creative/canva/renders/{id}
One Canva render: running | succeeded (the design id and the signed asset with a short-lived URL) | failed (a closed error code); a run interrupted by a restart reads as failed
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{render}404not found
Decline
POST /v1/decline/{id}
Decline staged action (reject_action)
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200declined
Deliverability
POST /v1/deliverability/dmarc-reports
Ingest one DMARC aggregate (RUA) report (E3.8): the raw gzip or zip attachment, or the XML. Size-capped, no DTDs or entities; a re-delivered report is a no-op
Scope: execute.
Request body (application/gzip)
string (binary)
Responses
200duplicate (already stored)201stored {reportId, orgName, domain, records, messages, passRate}413too large415not gzip, zip or XML422malformed, or a DTD/entity declaration
GET /v1/deliverability
Measured DKIM / SPF / DMARC status for the sending domain: DMARC pass rates by source (30 days of reports) and Gmail Postmaster Tools v2 when connected; not_measured otherwise (E3.8)
Scope: read.
Responses
200{domain, statuses, sources, dmarc, postmaster}
Email tool (ESP)
GET /v1/esp/metrics
Live per-message and per-day email metrics from the connected ESP(s), through governed reads (aggregates only, no people)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
provider | query | no | one of "customerio", "loops", "klaviyo" | |
days | query | no | integer 2–45 |
Responses
200{stats: [...], failures: [...]}404no ESP connected
GET /v1/esp/customerio/audience
Customer.io API-triggered broadcasts and segment ids/names (never members)
Scope: read.
Responses
200{region, broadcasts, segments}
POST /v1/esp/customerio/broadcast-triggers
Stage a Tier-2 customerio.triggerBroadcast to an existing segment, with the broadcast email snapshotted for approval and the approver's consent attestation; nothing is sent here
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
broadcastId | yes | integer | |
segmentId | yes | integer | |
attestation | yes | object | |
reasoning | no | string |
Responses
201{stagedActionId, preview, posture, …}400invalid body or the operation was refused (attestation, person-level fields)404broadcast or segment not found, or no Customer.io connected422the broadcast must have exactly one email message with a sender
Experiments
GET /v1/experiments
The tenant's experiments (E2.3 randomized holdouts) and the holdout consent text. #236: each experiment carries sourceActionId (the approved experiment_brief a draft came from, or null) and stopNotice (a met stopping rule the daily pass recorded: metAt, reason, notifiedAt, undeliveredAt, undeliveredReason; or null)
Scope: read.
Responses
200{experiments: [{…, sourceActionId, stopNotice}], supportedLoops, consent}
POST /v1/experiments
Draft a holdout experiment (admin): failed-payment-dunning (unit invoice), re-engagement or trial-conversion (unit Stripe customer, E3.10); defaults to a 10% control
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
loopId | no | one of "failed-payment-dunning", "re-engagement", "trial-conversion" | |
key | no | string | |
hypothesis | no | string (up to 1000 characters) | |
controlAllocation | no | number 0.05–0.5 | |
mde | no | number 0.01–0.5 | absolute, in rate units (0.10 = 10 pp) |
outcomeWindowDays | no | integer 7–90 | |
stoppingRule | no | object |
|
Responses
201{experiment, consent}400invalid input or loop not wired409key already used
POST /v1/experiments/{id}/start
Start a drafted holdout (admin). Needs the acknowledged consent: held-out units get no action
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
acknowledgeHoldout | yes | must be true | |
consentVersion | yes | string |
Responses
200running400consent not acknowledged (the body carries the terms)404not this tenant’s experiment409not a draft, or another experiment runs for the loop
POST /v1/experiments/{id}/stop
Stop enrolment (admin); held-out units stay out until their window ends. conclude:true freezes the result once nothing is pending
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
conclude | no | boolean |
Responses
200{experiment, pendingUnits, note?}404not this tenant’s experiment409not stoppable, or units still pending for conclude
GET /v1/experiments/{id}/results
Per-arm counts and outcome rates, lift with an always-valid 95% interval (and the posterior one), P(treatment > control), z-test, sequential power and the readiness caveat; the post-stratified lift, recovered money per enrolled unit by currency, and the partially pooled lift for the current holdout. W5-C1: every comparison (the rate and each currency) carries eValue and alwaysValidPValue (mixture SPRT, replayed over every window end, sequentialTest says how), and offPolicy has doubly robust values of treating every unit, none, or only where a model expects a lift, from the logged assignment propensities (directional; null when it cannot be estimated). unitReads says whether the units were read one by one (at most 100,000 assignments, in pages; past that the read is its counts alone). A read: it never records a met stopping rule (the daily pass does)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{experiment, scope: tenant, analysis, pendingUnits}404not this tenant’s experiment
External writes
GET /v1/external-writes
External-write adapters and the tenant's recent append-only attempt ledger
Scope: read.
Responses
200{adapters, attempts}
POST /v1/external-writes
Stage an external write (E4.0) for human approval: validated by its adapter, stored as the canonical operation the approval binds. Sends nothing
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
actionType | yes | string | |
payload | yes | object | the adapter's input; reasoning (≤1000 chars) is shown to the approver and never sent; voice (W4-B3) is written only by HeyCMO's voiced drafter and refused here (400) |
idempotencyKey | no | string (up to 200 characters) |
Responses
200the identical operation was already staged today201{stagedActionId, actionType, channels, target, preview, estimateSpendCents, posture}400the adapter refused the operation or its preview404no external-write adapter for actionType409a creative asset is refused (asset_unverified), a reversal was posted here (reversal_requires_request), or the adapter's staging check refused the write (staging_refused, with its reason, e.g. political_authorization_missing)
POST /v1/external-writes/cms-drafts
Send to CMS as draft (E3.5): stage one cms.publishPost draft built from a T-01 blog-post, T-02 page-copy or T-08 press-release draft. Sends nothing. An SEO content brief (either kind) or a GEO report is drafted with "Draft this" (POST /v1/content/drafts) instead (#233)
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sourceActionId | yes | string (uuid) | |
platform | no | one of "wordpress", "ghost", "webflow", "sanity", "contentful", "git" | required when several CMSs are connected |
Responses
200this source was already sent to that CMS201{stagedActionId, actionType, channels, target, preview, posture, source}404source action not found409no (or no single) CMS connected422not_cms_draftable — the source is not a T-01, T-02 or T-08 draft; an SEO content brief or a GEO report answers: use "Draft this" (POST /v1/content/drafts), which writes the article from the brief — a new page, or the changes to the page that already ranks
POST /v1/external-writes/landing-page-variants
W2-8: stage one cms.publishPost draft page per "## Variant <label>" section of a PB-12 landing-page draft (slug <page>-<label>, variant {of, label}) in a CMS that keeps landing pages (Sanity, Contentful, Git). Sends nothing; each page is its own approval
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sourceActionId | yes | string (uuid) | |
platform | no | one of "sanity", "contentful", "git" | required when several connections keep landing pages |
Responses
200every variant was already staged201{sourceActionId, platform, target, variants: [{label, slug, stagedActionId, alreadyStaged}], channels, posture}404source action not found409no (or no single) connection that keeps landing pages422the source is not a PB-12 landing-page draft, or it has no variant sections
POST /v1/external-writes/linkedin-drafts
Send to LinkedIn (E4.2): stage one LinkedIn post draft (text + link card, or text + a C2PA-signed creative asset as its image) from a content-repurposing report or a live published CMS post — a linkedin.publishPost by the Page, or (W2-2) a linkedin.publishMemberPost by the founder's own profile. W4-B3: the text is written in voice by the model and checked (never a template); its payload carries the voice record. Sends nothing
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sourceActionId | yes | string (uuid) | |
url | no | string | which content-repurposing candidate page (default: the first) |
assetId | no | string (uuid) | E4.4: one of the tenant's C2PA-signed creative assets, posted as the image (the link stays in the text) |
author | no | one of "page", "member" | W2-2: the Page or the founder's own profile; default the profile when the tenant chose it as an author, else the Page |
Responses
200this page was already sent to LinkedIn from this source201{stagedActionId, actionType, channels, target, preview, posture, sourceActionId, url}404source action or creative asset not found409LinkedIn not connected, no Page (or own profile, for author member) chosen, or the creative asset is not signed / fails verification; W4-B3: the voiced drafter refused ({code: no_model|model_off|budget|blocked_input|checks_failed|judge_failed|model_error, error} in plain words), nothing staged422the source is not a content-repurposing report or a live CMS post
POST /v1/external-writes/x-drafts
Send to X (W2-2): stage one x.publishPost draft (W4-B3: written in voice by the model and checked, with the page's link, within X's weighted 280; never a template; its payload carries the voice record) for the connected X account from a content-repurposing report or a live published CMS post. Sends nothing; once approved, X bills the post ($0.20 with a link) and the spend caps are checked before it is sent
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sourceActionId | yes | string (uuid) | |
url | no | string | which content-repurposing candidate page (default: the first) |
Responses
200this page was already sent to X from this source201{stagedActionId, actionType, channels, target, preview, estimateSpendCents, posture, sourceActionId, url}404source action not found409X not connected, or its account not recorded yet; W4-B3: the voiced drafter refused ({code: no_model|model_off|budget|blocked_input|checks_failed|judge_failed|model_error, error} in plain words), nothing staged422the source is not a content-repurposing report or a live CMS post
GET /v1/external-writes/ad-campaigns
Propose an ad change (C3): the campaigns a change can name, per connected network (Google Ads, Microsoft Advertising), read live — status, daily budget, own or shared budget, bidding strategy, the actions each can take and, for Google Ads, the ad groups whose bid a change may set. A network that cannot be read is listed as unavailable with why; nothing is invented. At most 500 campaigns a network are listed (Google Ads: lowest ids first); truncated says the account has more, and POST /v1/external-writes/ad-changes takes any campaign id, listed or not
Scope: read.
Responses
200{networks: [{network, name, target, currencyCode, campaigns: [{campaignId, name, status, dailyBudgetMicros, biddingStrategyType, actions: [pause|enable|budget|bid], budgetNote?, adGroups?: [{adGroupId, name, bid: cpc|target_cpa, bidMicros}]}], truncated}], unavailable: [{network, name, reason}]}
POST /v1/external-writes/ad-changes
Propose an ad change (C3): stage a pause, a turn-on, a daily budget change or (Google Ads) an ad group bid change as the same Tier-2 card the ad-fatigue producers would — the named campaign read live on its own (listed by GET /v1/external-writes/ad-campaigns or not), the same payload, the same staging as POST /v1/external-writes. Sends nothing; a person approves it in the inbox. A budget or bid change is bounded to ±30% per approval
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
actionType | yes | one of "googleads.updateCampaignStatus", "googleads.updateCampaignBudget", "googleads.updateAdGroupBid", "microsoftads.updateCampaignStatus", "microsoftads.updateCampaignBudget" | |
campaignId | yes | string | |
status | no | one of "paused", "enabled" | status changes |
deltaPct | no | number -30–30 | budget and bid changes: a percent change, not 0 |
amount | no | string | budget and bid changes, instead of deltaPct: the new amount in the account currency, e.g. "120.50" |
adGroupId | no | string | bid changes |
reasoning | no | string (up to 1000 characters) | shown to the approver; never sent |
Responses
200the same change is already staged today (the same card)201{stagedActionId, actionType, channels, target, preview, estimateSpendCents, capRefusal, network, action, pacing: {headline, status, fits, note}|null, posture}400invalid body, or the adapter refuses the change (code invalid_operation, e.g. beyond ±30%)403read-only key404campaign_not_found or ad_group_not_found in the connected account409not_connected, unreadable (connected, but its campaigns could not be read just now), nothing_to_change (already paused…) or shared_budget
GET /v1/external-writes/applied
Recent external writes the provider accepted live, each with whether a person can reverse it now (and why not)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
limit | query | no | integer 1–50 |
Responses
200{writes: [{id, actionType, provider, channels, target, title, executedAt, externalRef, reverses, reversal, outcome: {metricId, why, page?: {url, isNew}, readbacks: [{metricId, windowDays, changeDay, readiness, outcome, label, beforeMean, afterMean, difference, relativeChange, trust, causal: false, note, page: {url, isNew, clicks: {before, after}, impressions: {before, after}, position: {before, after}} | null}]}}], more} — more: older applied writes exist past this page; #235 outcome: the metric the write is meant to move and its recorded 14- and 28-day readbacks (directional, never causal); #233: a CMS write that changed a live page targets that page (page), read back on it in Search Console at 14, 28 and 56 days, and a draft save targets nothing
POST /v1/external-writes/{actionId}/reverse
Request the reversal of one applied external write: stages a Tier-2 <actionType>.reversal item bound to exactly that write (its staged action, approved operation hash, operation digest and provider ref). Sends nothing; a person approves it in the inbox
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
actionId | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
reasoning | no | string (up to 1000 characters) | why, for the approver |
Responses
200the same reversal was already staged (a concurrent request)201{stagedActionId, actionType, channels, target, preview, estimateSpendCents, reverses, compensation, posture}404no write with this id in this workspace409not applied, a reversal is pending, or the write was already reversed (code, reversalActionId)422not reversible: the adapter cannot undo it, it was rehearsed, it is itself a reversal, or undoing it would not restore what was there before
Factory
GET /v1/factory/runs
List this workspace’s campaigns, newest first (read scope)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
limit | query | no | integer 1–100 default 20 | |
approved | query | no | boolean default false | Only campaigns with a currently approved, executing or executed card; filtered before limiting. |
open | query | no | boolean default false | Only campaigns still under way (running, or waiting on a person); filtered before limiting. |
Responses
200{items: FactoryRunRecord[], count}
POST /v1/factory/runs
Plan and draft a campaign from a marketing goal (execute scope)
Reads tenant context. Missing audience, message or destination holds the plan for correction. checkpointBlueprint requests plan review before drafting; MCP always requests it. Drafts stage in the approval inbox and never publish directly.
Scope: execute.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
goal | yes | string (at least 1 characters) | |
targetIcp | no | string | The intended audience; missing context holds drafting for correction. |
pillar | no | string | Customer-approved message or product fact, never an achieved campaign target. |
ctaUrl | no | string (uri) | The product destination; HTTP(S), without embedded credentials. |
channels | no | array of one of "cms", "linkedin", "x", "email", "ads", "landing" | |
measurement | no | object or null | Omit to keep the saved target on review; null explicitly removes it.
|
triggerKind | no | one of "custom_goal", "competitor_move", "product_launch", "churn_recovery", "seo_gap" | |
targetKeywords | no | array of string | |
checkpointBlueprint | no | boolean default false | |
useLlm | no | boolean default true | Use configured model generation; false uses clearly labelled templates. |
Responses
201FactoryRunRecord: blueprint, drafts, checks, approval state and generation provenance400Missing goal or invalid measurement403execute scope required
GET /v1/factory/runs/{runId}
Read this workspace’s campaign and current edited copy and delivery state (read scope)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string |
Responses
200FactoryRunRecord, hydrated from current staged actions404Campaign not found in this workspace
POST /v1/factory/runs/{runId}/resume
Correct or accept a campaign plan, or finish reviewing its drafts (approve scope)
Plan acceptance drafts into the inbox. decision=approve_all requires a person where dashboard identity is configured; a machine caller receives an approveUrl. approve_all names, in operationHashes, the operation hash of each draft as shown (stagedActions[].operationHash on the run read): if any waiting draft is missing or has changed since, nothing is approved (409). complete requires all staged items to have been reviewed. Invalid or concurrent transitions return 409. MCP exposes only plan correction/acceptance.
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
targetIcp | no | string | The intended audience; missing context holds drafting for correction. |
pillar | no | string | Customer-approved message or product fact, never an achieved campaign target. |
ctaUrl | no | string (uri) | The product destination; HTTP(S), without embedded credentials. |
channels | no | array of one of "cms", "linkedin", "x", "email", "ads", "landing" | |
measurement | no | object or null | Omit to keep the saved target on review; null explicitly removes it.
|
approved | no | boolean | |
editedGoal | no | string | |
decision | no | one of "approve_all", "decline_all", "complete" | |
operationHashes | no | object | approve_all: staged action id → the operation hash shown |
Responses
200Updated FactoryRunRecord400Invalid input or missing campaign context403approve scope or human_approval_required with approveUrl404Campaign not found409Campaign is not at the requested review step, still has unreviewed drafts, or (approve_all) a draft changed since it was shown; nothing is approved
GET /v1/factory/runs/{runId}/results
Read campaign delivery counts and its selected workspace metric (read scope)
Returns published, reviewedDrafts, pending, failed, unknown, rehearsed and declined counts. Measurement includes the observation’s source and trust; targetMet stays null until a due target has a trusted observation collected since this campaign began. Workspace metrics are not campaign attribution or causal lift.
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string |
Responses
200FactoryResults {delivery, measurement}404Campaign not found
POST /v1/factory/runs/{runId}/results
Record a sourced result review for future planning (execute scope)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string |
Responses
200FactoryResults; evidence saved with causal:false and an audit record400A due goal and a trusted observation collected since the campaign began are required403execute scope required404Campaign not found
GET /v1/factory/prompt-specs
W5-B1 (#277): the prompt optimizer's compiled channel prompt specs: for each channel (linkedin, x, cms, email, ads, landing) its dataset cases (founder edits and text declines of the last 180 days), its active spec (the rules learned from those edits, the phrases to avoid, the few-shot cases by id, its validation score against the spec it replaced) and every version kept (active, superseded, rolled_back)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
channel | query | no | one of "linkedin", "x", "cms", "email", "ads", "landing" |
Responses
200{enabled, channels: [{channel, cases, active: {version, status, rules: [{feature, text, support, addedInVersion, maxChars?}], bannedPhrases, fewShotCases: [{caseId, actionType, editDistanceRatio}], baselineScore, candidateScore, caseCount, gradientSource: rules|model, parentVersion, compiledAt, statusChangedAt} | null, versions: [same, newest first]}]}400unknown channel
POST /v1/factory/prompt-specs/compile
W5-B1: compile a channel's prompt spec now (the same compile every third new founder edit or decline starts): the backward critique of its cases, the candidate instructions scored leave-one-out by the founder-edit alignment scorer and the factory scorer suite, and promotion of a new version only when the best candidate scores strictly above the active spec; audited either way
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
channel | yes | one of "linkedin", "x", "cms", "email", "ads", "landing" |
Responses
200{channel, joined (true: a compile of this channel was already running in this API task, automatic or on demand; this is its result), trigger (auto|manual: the compile reported), promoted, version, reason, caseCount, baselineVersion, baselineScore, best: {id, score}, candidates: [{id, label, score, alignment, suite, newRules, newPhrases}], gradient: {source, violatedRule, proposedInstructionDelta, bannedPhraseAdditions, spanCritiques}, modelNote, fewShotCases, spec? (when promoted)}400body must name a channel403read-only key409the prompt optimizer is off (HEYCMO_PROMPT_OPTIMIZER=off)
POST /v1/factory/prompt-specs/rollback
W5-B1: roll a channel's prompt spec back: the active version becomes rolled_back and the version before it (or toVersion; none = HeyCMO's base prompt) becomes active. Nothing is deleted; a rolled-back rule comes back only on edits newer than the rollback. Audited
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
channel | yes | one of "linkedin", "x", "cms", "email", "ads", "landing" | |
toVersion | no | integer ≥ 1 |
Responses
200{ok: true, channel, from, to} (to: null = the base prompt)400invalid channel or toVersion403read-only key404unknown_version409no_active: no active spec to roll back
GET /v1/factory/prompt-specs/quality
C5 (#369): how the workspace's drafts fared by loop, action type and the prompt-spec version they were drafted with (null: none), over the cards staged in the last days (default 90): cards staged, approved, declined and edited by a person, the decline rate, and each scorer's mean score (the Factory's checks and the live judge's sampled grades). Ids and numbers only
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
loop | query | no | string (up to 200 characters) | |
days | query | no | integer 1–730 |
Responses
200{days, rows: [{loop, actionType, promptSpecVersion, staged, approved, declined, edited, declineRate (null before any decision), scores: {<scorer id>: {mean, count}}}]}400days not a whole number from 1 to 730, or loop too long
Founder post
GET /v1/founder-post
C6 (#237): the founder-post loop's cadence (posts a week, days, networks; stored: false while the defaults are in effect) and "What will post this week": each planned post with its day, network, topic, status, the first words of the post and its 48-hour numbers (or why there are none)
Scope: read.
Responses
200{loopId, settings: {postsPerWeek, days, networks, stored, updatedAt}, week: {weekStart, settings, destinations, posts: [{slot, day, date, network, candidateKind, topic, status: waiting|approved|posted|rehearsed|declined|failed|not_staged, stagedActionId, actionType, note, excerpt, readback}]}}
PUT /v1/founder-post
Set the founder-post cadence (admin, audited): postsPerWeek (1–7), days (mon…sun), networks (linkedin|x|facebook; empty = every connected one); only the keys sent change
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
postsPerWeek | no | integer 1–7 | |
days | no | array of one of "mon", "tue", "wed", "thu", "fri", "sat", "sun" (at least 1) | |
networks | no | array of one of "linkedin", "x", "facebook" |
Responses
200{ok, loopId, settings, week} (as GET)403read-only key or no admin scope422invalid_settings with issues [{path, message}]
GEO (AI answers)
GET /v1/geo
GEO visibility (E3.4): engine status, the LLM Mentions baseline status, the prompt set, cadence, the newest complete panel (share of voice, citation share, gaps, week-over-week movement, guarded recommendations, metric ids, the mentions baseline; W5-C3: mean PAWC, attributed word share and cited-domain entropy, drifted answers and the drift list, absent on a panel summarized before them, and driftGrounded: whether the price and capability rules of the drift check ran against the product context), the trend (with pawc and driftAnswers per panel), a panel in progress, the Search Console exports on file and the settings (the newest panel is read under the current Google AI exclusion)
Scope: read.
Responses
200{engines, mentionsSource, loop, cadence, promptSet, latest (design v2: with gapFixes, each citation gap's fix by prompt id: {briefActionId, briefStatus, query, draftActionId, draftStatus, draftRehearsed}, from the newest GEO page brief of the last 120 days that lists it and the page drafted from it), trend, inProgress, policy, searchConsoleExport, settings}
GET /v1/geo/settings
The tenant's GEO settings: whether its site is excluded from Google's AI features in Search Console
Scope: read.
Responses
200{settings: {excludedFromGoogleAi}}
PUT /v1/geo/settings
Say whether the site is excluded from Google's AI features in Search Console (admin; no API reports it). When it is, AI Overview and AI Mode answers are not counted as citation gaps and get no recommendation
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
excludedFromGoogleAi | yes | boolean |
Responses
200{settings}400invalid input
GET /v1/geo/runs/{id}
One GEO panel run's answers with their analysis (W5-C3: pawcScore, attributedWordShare, domainEntropy, citationDriftDetected and citationDrift findings per answer, null on an answer stored before them); answer excerpts are third-party text
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{run, summary, metricIds, results}404not this tenant's run
GET /v1/geo/prompt-sets
The GEO prompt set's versions (every save is a new immutable version)
Scope: read.
Responses
200{versions}
GET /v1/geo/prompt-sets/{version}
One GEO prompt-set version
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
version | path | yes | integer ≥ 1 |
Responses
200{promptSet}404no such version
PUT /v1/geo/prompt-set
Save a new GEO prompt-set version (admin). A daily cadence needs the paid add-on
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
prompts | yes | array of object (at least 1, at most 200) |
|
brand | yes | object |
|
competitors | no | array of object (at most 10) | |
engines | no | array of one of "claude", "openai", "gemini", "perplexity", "google_ai_overview", "google_ai_mode", "dataforseo", "bing_copilot", "dataforseo_ai_mode", "chatgpt_ui", "gemini_ui" | |
cadence | no | one of "weekly", "daily" | |
budgetCentsPerRun | no | integer ≥ 0 |
Responses
200{promptSet}400invalid input402daily cadence without the add-on
POST /v1/geo/prompt-set/seed
Propose a GEO prompt set from Search Console queries (or an uploaded Queries export) and the product context (admin); saved unless save is false
Scope: admin.
Responses
200{draft, notes} (save: false)201{promptSet, notes}422nothing to seed from
POST /v1/geo/gaps/{promptId}/plan
Design v2 AI answers, "Plan this fix" (execute): a page brief (seo_content_brief, kind new_page, source geo) for one citation gap of the newest complete panel, enriched like the weekly check's and put on the editorial calendar; a question that already has a brief gets it back
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
promptId | path | yes | string |
Responses
200{briefActionId, alreadyStaged: true, query}: the question already has a brief201{briefActionId, alreadyStaged: false, query}403read-only key404not_a_gap: no such question, or no assistant links a competitor and not you for it in the newest panel409no_panel: no AI answers check has finished yet
PUT /v1/geo/search-console-export
Upload one Search Console table export (execute): the Performance report's Queries table (seeds prompts without a Search Console connection) or the Generative AI report's Dates table (Google's own AI Overview / AI Mode impressions); replaces the earlier upload of that report
Scope: execute.
Request body (text/csv)
string (up to 262144 characters)
Responses
201{export: {report, uploadedAt, rows, from, to, totalImpressions}}413over 256 KB or 1,000 rows415not one of the two accepted Search Console tables422a row that does not parse (row number and column named)
Governor
GET /v1/governor/policy
A2: the caps the governor enforces now — sendPerDay (sendPerRun is a deprecated read alias of the same value), changePerRun, massAudienceThreshold, the usd spend caps, spend caps per other currency, the model-spend limit and the always-escalate list
Scope: read.
Responses
200{tenantId, plan, caps: {sendPerDay, sendPerRun, changePerRun, massAudienceThreshold, spendDailyCents, spendWeeklyCents}, spendCapsByCurrency, llm: {dailyCents, weeklyCents, dailySource, weeklySource}, alwaysEscalate}
Inbox
GET /v1/inbox
Approval inbox (list_pending_actions)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
status | query | no | string | |
actionId | query | no | string (uuid) | Only this tenant-owned action, filtered before the 100-row limit; missing or nonmatching actions return an empty list |
loop | query | no | string | Only this loop's items (a loop id from GET /v1/loops), filtered before the 100-row limit |
Responses
200inbox items + capStatus400Invalid status or actionId401auth
GET /v1/inbox/track-record
Decisions people made per loop and action type (approved, declined, edited) over the last days days — the inbox card's track record
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
days | query | no | integer 1–365 default 90 |
Responses
200{windowDays, records: [{loop, actionType, decided, approved, declined, edited, editedApproved, outcomes: {byWindow: [{windowDays, readbacks, improved, declined, flat, increased, decreased, notReadable, newPages}], direction, latest: [{stagedActionId, metricId, windowDays, changeDay, readiness, outcome, label, beforeMean, afterMean, difference, relativeChange, trust, page}], causal: false, note} | null}], definition} — #235 outcomes: the 14- and 28-day readbacks of the metric each applied write is meant to move, directional, never causal; outcome improved | declined (by the metric's good direction), increased | decreased (a metric with none: never better or worse), flat (barely moved); #233: readbacks of a published or edited page on that page (14, 28 and 56 days;pagegives its Search Console clicks, impressions and position;newPagescounts new pages, which have no before); C6 (#237): posts — {read, impressions, engagements, notExposed, rehearsals, unavailable, latest} for social posts read back 48 hours after publishing; coverage — {sent, quoted, latest: [{outcome, outlet, url, markedAt}]} on the journalist desk's fact sheets' record
Ingest
PUT /v1/ingest/{kind}
Partner sense ingest (tracking|activity|ads|…)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
kind | path | yes | string |
Responses
200snapshot upserted
Journalist desk
GET /v1/journalist-desk
Journalist desk (#173): the address to forward HARO, Featured, Qwoted, Source of Sources and MentionMatch digests to, forwarding settings, the Gmail forwarding confirmation code while set-up is pending, the last message's outcome, the services, what requests are matched against, and stored requests with deadlines, matches and fact sheets: the open ones soonest deadline first (requests, up to 100, with openCount), then the latest past their deadline (closed, up to 20, with closedCount)
Scope: read.
Responses
200address, loop, settings, forwardingConfirmation, lastMessage, services, expertise, retention, requests (each with body (at most 1,200 characters) and requirements, the lists of its fact sheet, factSheet: {actionId, status, facts, numbers, talkingPoints, howToAnswer} | null, and coverage: {outcome, url, markedAt} | null, C6; design v2 Press: skippedAt, when it was set aside, and sheetEmailedAt, when the caller last emailed themselves its fact sheet, each an ISO time or null), openCount, skippedCount (the open requests set aside, still listed), closed, closedCount, coverage: {windowDays, sent, quoted, latest: [{id, outcome, outlet, url, service, markedAt, requestListed}]} (C6; a tick whose request was deleted is changed at PUT /v1/journalist-desk/coverage/{id})
PUT /v1/journalist-desk
Set journalist desk settings (admin): forwardingSenders (addresses or company domains the tenant forwards from), topics
Scope: admin.
Responses
200updated400invalid or unknown field
PUT /v1/journalist-desk/requests/{id}/coverage
C6 (#237): the "sent / quoted / link" tick on a journalist request: what came of answering it (null clears it). Kept by the request's service and key, so it outlives the request and feeds the track record (coverage). Audited
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
outcome | yes | one of "sent", "quoted" | |
url | no | string (up to 2048 characters) or null | the published coverage (http or https) |
Responses
200{ok, coverage: {outcome, url, markedAt} | null}400invalid body403read-only key404no such request
PUT /v1/journalist-desk/requests/{id}/skip
Design v2 Press: "Skip this request". Sets the request aside (it stays listed; the journalist-desk loop prepares no fact sheet for it, and a fact sheet already waiting stays in the Inbox) or, with skipped: false, brings it back (Undo). Audited when it changes
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
skipped | yes | boolean |
Responses
200{ok, skippedAt: ISO time | null}400invalid body403read-only key404no such request
POST /v1/journalist-desk/requests/{id}/email
Design v2 Press: "Email me the fact sheet". Emails the request's fact sheet from the platform sender to the signed-in person's own address (the verified session token's email claim, else the one their account kept) and nobody else. Only a person signed in to the dashboard (a Clerk or AuthKit session), at most 10 an hour each; audited without the address. Nothing is ever sent to a journalist
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{ok, sentTo: the address it went to, emailedAt}403a key, an MCP client or the CLI (code person_required), or a read-only session404no such request409no fact sheet yet (code no_fact_sheet), or the sign-in has no email address (code no_email)429ten this hour already (code rate_limited)502the email could not be sent (code email_failed)503platform email is not set up on this deployment (code email_unconfigured)
PUT /v1/journalist-desk/coverage/{id}
C6 (#237): the same "sent / quoted / link" tick changed by its own id (GET /v1/journalist-desk coverage.latest[].id): the piece often appears after its request was deleted (a week after its deadline). Null clears it. Audited
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
outcome | yes | one of "sent", "quoted" | |
url | no | string (up to 2048 characters) or null | the published coverage (http or https) |
Responses
200{ok, coverage: {outcome, url, markedAt} | null}400invalid body403read-only key404no such tick
GET /v1/journalist-desk/podcast-cards/{id}
A podcast guest card (podcast_guest_fact_sheet) as it is shown. One Listen Notes found keeps only the episode's id and publication date (Listen Notes' API terms for FREE and PRO allow storing nothing else), so the show, the episode, what was said and when, why it matched and the fact sheet are read from Listen Notes again now, by sending the search word that found it again, narrowed to that episode: one request against the workspace's monthly Listen Notes budget, under the listening kill switch, returned with "Powered by Listen Notes" and never stored (cache-control: private, no-store). A Podscan or Podcast Index card comes back as stored, with no request
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{id, status, operationVersion, live, card}: card is the payload as shown; with live, its live.read says whether Listen Notes was read now, and live.note why not404not this workspace's podcast guest card
Kill switch
GET /v1/killswitch
Kill switch status: tenant switch, tenant channel stops, engaged platform stops, env ceilings
Scope: read.
Responses
200{killSwitch, channels, platform:{global,channels,tools}, engaged, ceilings}503switch state unreadable (execution refuses fail-closed)
POST /v1/killswitch
Engage (execute|admin) / release (admin; where people sign in, a signed-in person only) the tenant-wide kill switch
Scope: execute or admin to turn it on, admin to turn it off, and wherever people sign in only a signed-in person turns it off.
Responses
200updated403scope, or person_required (a key, an OAuth client or the CLI releasing where people sign in)
POST /v1/killswitch/channels/{channel}
Engage (execute|admin) / release (admin; where people sign in, a signed-in person only) one tenant channel stop
Scope: execute or admin to turn it on, admin to turn it off, and wherever people sign in only a signed-in person turns it off.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
channel | path | yes | one of "email", "ads", "cms", "indexnow", "crm", "social", "llm", "notifications", "research", "seo-data", "listening", "geo", "creative", "esp", "reviews", "product-analytics", "conversions" |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
enabled | yes | boolean | |
reason | no | string (up to 500 characters) |
Responses
200updated400bad channel/body403scope, or person_required (a key, an OAuth client or the CLI releasing where people sign in)
Landing pages
GET /v1/landing-pages
Hosted landing-page variant tests (W3-7): whether the variant host is set up on this deployment (and its CNAME target), the landing-pages write posture, the tenant's hostnames with the DNS records to add, its tests, landing-page drafts with ## Variant sections, publish and stop cards waiting in the inbox, the terms an approver accepts, and the plan's defaults and bounds
Scope: read.
Responses
200{host, posture, hostnames, tests, drafts, pending, terms, defaults, bounds, goalKinds, stripeGoalWindows}
POST /v1/landing-pages/hostnames
Claim a hostname you own (a subdomain such as try.example.com) for hosted landing-page tests (admin; audited; a paid plan or Alpha). Nothing reaches the host yet: answers the DNS records to add — the _heycmo-challenge TXT record that proves this workspace controls the hostname, and the CNAME to the CNAME target
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
hostname | yes | string |
Responses
201{hostname: {id, hostname, status: pending|active|failed, sslStatus, dnsRecords, lastError, ownershipVerifiedAt, verifiedAt}}400invalid_hostname (an apex domain, a URL, a reserved or HeyCMO name)402plan_entitlement: a no-card trial, no plan, or a restricted workspace409hostname_connected, hostname_taken (another workspace holds it verified), hostname_limit, or variant_host_unconfigured423a kill switch stops the landing-pages channel
POST /v1/landing-pages/hostnames/{id}/check
Check a hostname (admin; at most once a minute): first the _heycmo-challenge TXT record (proof of control, which takes the hostname over from other workspaces' unproven claims), then the host; active once the host reports the hostname and its certificate active. A hostname the host no longer has turns failed and is registered again on the next check
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{hostname}; lastError awaiting_ownership while the TXT record is missing404not this tenant's hostname409hostname_taken, variant_host_unconfigured or not_ready429check_cooldown (retryAfterSeconds), or the host is rate limiting502the host refused (closed code)503dns_unavailable, or the host is unavailable or its hostname quota is used up
DELETE /v1/landing-pages/hostnames/{id}
Remove a hostname and its certificate at the host (admin; audited); refused while a test is served on it, except in a restricted workspace, whose test is taken down with it. Answers the DNS records the customer should now delete
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{removed, id, testsTakenDown, dnsCleanup: {message, records}}404not this tenant's hostname409hostname_in_use
POST /v1/landing-pages/tests
Stage the publish of a landing-page draft (PB-12, 2–4 ## Variant sections) as a hosted test on a verified hostname: one Tier-2 landingpages.publishVariants card with every variant, the split, the conversion goal (default: the first https link of the control) and the plan. Nothing is published until a person approves it. #235: goalKind trial_started or first_paid_invoice counts a live Stripe trial or first paid invoice of a visitor who followed a goal link (joined through the hc_ref the link carries to the site; measurement window 14 or 45 days by default, at most 30 or 60) instead of the click
Scope: execute.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
sourceActionId | yes | string (uuid) | |
hostnameId | yes | string (uuid) | |
goalUrl | no | string (uri) | |
goalKind | no | one of "link", "trial_started", "first_paid_invoice" | |
weights | no | object | |
mde | no | number 0.01–0.3 | |
outcomeWindowDays | no | integer 1–60 | at most 14 for a link goal, 30 for trial_started, 60 for first_paid_invoice |
plannedDays | no | integer 14–90 | |
maxVisitors | no | integer 1000–1000000 | |
hypothesis | no | string (up to 1000 characters) |
Responses
200the same card was already staged today201{stagedActionId, alreadyStaged, actionType, channels, target, preview, sourceActionId, hostname, variants, goalUrl, goalKind, posture}400invalid input, or the operation refused (invalid_operation)404source or hostname not found409hostname_not_verified422not_landing_page, source_declined, no_variants, too_many_variants, no_goal
GET /v1/landing-pages/tests/{id}/results
A hosted test's read-out with the holdout statistics (#164): per variant visitors, measured visitors and conversions, the rate with its interval; each variant against the control with the always-valid interval (Bonferroni across comparisons), its always-valid p-value and e-value (W5-C1, replayed visitor by visitor up to 100,000 measured visitors, unitReads says which; null while not_enough_data), power and the label not_enough_data | leaning | decided | no_clear_winner
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{test, experiment, generatedAt, enrollment, readout: {verdict, headline, arms, comparisons, daysRunning, plannedDays, minDays, method}, winner (#236: the variant a decided read-out names, else null), promotion (#236: {label, at, unmeasured, serving} once an approved promotion concluded the test, with readout the one it froze; serving false after its undo), or null}404not this tenant's test
POST /v1/landing-pages/tests/{id}/stop
Stage the stop of a live hosted test: one Tier-2 landingpages.stopTest card. Once approved, new visitors see the control and are not measured; enrolled visitors keep their variant until their window ends
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
201{stagedActionId, alreadyStaged, preview, posture}404not this tenant's test409not_live
POST /v1/landing-pages/tests/{id}/promote
Stage the promotion of a decided hosted test's winner: one Tier-2 landingpages.promoteWinner card bound to the test, its version and the variant (default the read-out's winner). Once approved, every visitor sees that variant and the test is concluded with its read-out frozen; reversible (the control is served again)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
variant | no | string |
Responses
201{stagedActionId, alreadyStaged, preview, posture, testId, variant}404not this tenant's test409not_decided | not_winner | not_promotable (taken down, already promoted or concluded), in words
Loop specs
GET /v1/loop-specs
Tenant dynamic loop specs (loops-as-data Wave C)
Scope: read.
Responses
200AI-authored specs with status
POST /v1/loop-specs/{loopId}/retire
Retire a dynamic loop spec (admin)
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
loopId | path | yes | string |
Responses
200retired404no such spec
Loops
GET /v1/loops
Loop catalog (list_loops)
Scope: read.
Responses
200loops with runnable/enabled, enabledAt (when it was turned on; null while off, or on since before this was kept), cron and next scheduled run; B6: purpose, actsWhen and output (the catalog anatomy), requires ([{source, label, remedy}]: any one lets the loop sense; [] = its own records), alsoReads ([{source, label}]) and lastRun ({startedAt, finishedAt, outcome, firstRun, sensed, eligible, staged, actionable} | null) for every loop; B5: overrides[] {param, label, unit, from (the code default), to, appliedAt, inForce}, the settings an approved portfolio review tuned (a person's own saves are only in settings[]); design v2: settings[] {name, label, unit, role, default, value (in force), min, max, set: {at, by: person | review} | null}, every setting the loop registers
PUT /v1/loops/{id}/params
Design v2 Job detail, "Save settings" (admin): set some of a loop's registered settings, each a whole number inside the bounds its runs honour (its range and half to one and a half times its default). A value equal to the default removes the override; the whole save is refused when one value is not allowed. Each change is audited loop_param_override_set
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
params | yes | object |
Responses
200{settings (as GET /v1/loops settings[]), changed: [{name, before, after}]}400invalid body, a setting the loop does not have (code unknown_setting), or a value out of range (code out_of_range)403admin scope required, or a read-only key409the loop was never turned on in this workspace (code not_set_up), or its settings kept changing (code busy)
GET /v1/loops/{id}/outcomes
What one loop found and what came of it (B6): its last runs, the items each staged with the decision and outcome (waiting, in_progress, declined, sent, rehearsed, applied, reversed, acknowledged, failed, blocked) and payments recovered after them, and its track record
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | |
limit | query | no | integer 1–20 default 10 |
Responses
200{loopId, runs: [{id, startedAt, finishedAt, outcome, firstRun, sensed, eligible, staged, actions: [{id, actionType, status, outcome, createdAt, decidedAt, executedAt, recovered: [{currency, cents}], readback? (C6: a social post's 48-hour numbers)}]}], trackRecord, windowDays, week? (C6: founder-post only — What will post this week, as GET /v1/founder-post)}
GET /v1/loops/{id}/runs
A loop's 20 most recent runs (list_loop_runs)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{runs: [{runKey, startedAt, outcome, actionable, sensedCount, stagedCount, executedCount}]}
POST /v1/loops/{id}/run
Run a loop now (builtin, skill-compiled or dynamic). A call with an API key, from the CLI or from a dashboard session is a new run that senses again; dedupe and staged-action idempotency keys keep it from staging one signal twice. A call with an MCP OAuth token keeps one run per day (the runDate, default today UTC): a second call that day returns that run (resumed: true)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | |
runDate | query | no | string (date) | The window date the run key names (default today, UTC); date is accepted too |
Responses
200harness result {outcome, actionable, resumed, sensed, eligible, staged, skippedByCooldown, skippedByDedupe, firstRun, selfCheck, logs, runId, startedAt, earlierRunStartedAt?}; resumed: true only for a window that already ran, with that run's recorded selfCheck and startedAt; earlierRunStartedAt: on a run that sensed again on a day the loop already ran (the tenant's time zone), when the latest earlier run that day started. B6: a listening loop that is on collects mentions first and addscollection(as POST /v1/tenant/listening/collect answers); a run whose self-check found no data addsremedies(what to connect, and where)
POST /v1/loops/{id}/stage-backlog
Stage the first-run backlog for review (admin; approval-gated loops only — nothing is sent)
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200harness result (backlog: true)409regular runs already staging this loop422loop is not backlog-stageable
POST /v1/loops/failed-payment-dunning/stage-rehearsal
Stage the Day-0 sample recovery email (W5-A4): a made-up customer and payment, drafted by the recovery loop's drafter in the workspace's brand template and staged as a recovery_rehearsal card (payload.rehearsal: true). Approving it acknowledges it and sends nothing, to anyone; it counts as the session's first approval (the inbox checklist's hand-off) but in no metric. Nothing is read from Stripe. A waiting rehearsal is withdrawn when real work is staged
Scope: execute.
Responses
200{rehearsal: true, stagedActionId, alreadyStaged, sent: false}; alreadyStaged: one was already waiting403read-only key404{error, code: rehearsal_not_found}: the workspace does not exist409{error, code: rehearsal_kill_switch | rehearsal_already_approved | rehearsal_real_work_waiting, waitingActionId?}: the kill switch is on (nothing could be approved), the workspace approved something already, or real work is waiting (its id)
POST /v1/loops/register-catalog
Register the 45-loop catalog + 2 platform loops into loop_registry (idempotent)
Scope: admin.
Responses
200register report
Marketing
GET /v1/marketing/operations
Read all 52 marketing skills, pinned methodology availability and actual native provider readiness
Scope: read.
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
GET /v1/marketing/accepted-artifacts/{workId}/{taskId}
Read exact accepted RevOps content and provenance for review context; not fact certification or a provider call
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
workId | path | yes | string (uuid) | |
taskId | path | yes | string |
Responses
200Exact UTF-8 artifact, original provenance and immutable acceptance; no Markdown field extraction.403Read scope required.404Missing, foreign, unaccepted, canceled, changed, erased or unverifiable source; no existence disclosure.
GET /v1/marketing/email-sequences/{runId}/{stepId}
Read the exact seven-message HTML handoff of a completed live source-exact email sequence; no provider calls
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string | |
stepId | path | yes | string |
Responses
200Full-source identity remains separate from deterministic escaped HTML; draft only, not delivery or model-quality proof.403Read scope required.404Missing, foreign, incomplete, rehearsed, changed or erased source; no existence disclosure.
GET /v1/marketing/work
List marketing work and result review dates
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
open | query | no | boolean | Return only planning, active and review-due work before applying the result limit. |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work
Create a plan only; does not start tasks or approve external changes
Scope: execute.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
goal | yes | string (at least 1 characters, up to 2000 characters) | |
reviewOn | yes | string (date) | |
metricId | no | string (up to 200 characters) | |
tasks | yes | array of object (at least 1, at most 30) |
|
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/plan
Propose a bounded cross-function work plan from company context; metered model call, no external approval
Scope: execute.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
goal | yes | string (at least 1 characters, up to 2000 characters) | |
reviewOn | yes | string (date) | |
metricId | no | string (up to 200 characters) |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
GET /v1/marketing/work/{id}
Read one work plan with task dependencies and execution evidence
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/{id}/revise
Replace a plan before acceptance; optimistic version check
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
version | yes | integer ≥ 1 | |
plan | yes | object |
|
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/{id}/cancel
Close work; child Inbox decisions and applied changes retain their own state
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
version | yes | integer ≥ 1 |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/{id}/refresh
Refresh provider-confirmed progress; never infers delivery from an acknowledgement
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/{id}/accept
Person accepts the plan only; context and version must still match
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
version | yes | integer ≥ 1 |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/{id}/confirm
Person accepts analysis or confirms outside work; optional displayed artifactHash binds exact bytes and methodology, never a native receipt
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
version | yes | integer ≥ 1 | |
taskId | yes | string | |
summary | yes | string | |
url | no | string (uri) | |
artifactHash | no | string |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/{id}/review
Person records sourced results and next work; selected metrics need a trusted post-start observation
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
version | yes | integer ≥ 1 | |
note | yes | string | |
nextWork | yes | string |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
POST /v1/marketing/work/{id}/tasks/{taskId}/start
Start a task after acceptance and prerequisites; native changes stage separate Inbox approvals
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) | |
taskId | path | yes | string |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
version | yes | integer ≥ 1 |
Responses
200Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.403Insufficient scope or person-only decision.409Stale version, changed context, prerequisites pending or provider evidence unavailable.422Invalid bounded input, date, task graph, metric or operation.
Marketing state
GET /v1/marketing-state
The tenant's marketing state (get_marketing_state; B1): ICP segments, goals with targets, channel priorities, budget envelope, named competitors, words to avoid and messaging pillars, with its version and newest 20 changes
Scope: read.
Responses
200{version (0 = none yet), state: {icpSegments, goals, channelPriorities, budget, competitors, doNotSay, messagingPillars}, updatedAt, updatedBy, source: api|strategy|playbook|null, changelog: [{fromVersion, toVersion, fields, source, sourceRef, reason, proposedBy, acceptedBy, createdAt}], listeningCompetitors: {competitors: [{name, domain}] (the listening settings' companies), watched (every name monitoring watches: those companies, then the competitor search terms), match, onlyInMarketingState, onlyInListening (compared with watched)}}
PUT /v1/marketing-state
Replace the named marketing-state fields; the others keep their value (admin, audited). A change bumps the version and adds a changelog row; an unchanged write changes nothing
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
expectedVersion | no | integer ≥ 0 | refuse unless the record is at this version (0 = none yet) |
reason | no | string (up to 500 characters) | |
icpSegments | no | array of object (at most 5) | Each item: {id?, name, firmographics?: {industries[], employeesMin, employeesMax, regions[], notes}, pains[], disqualifiers[]} |
goals | no | array of object (at most 8) | Each item: {id?, metric (metric-registry id), target (in the metric unit: minor units for money, 0–1 for a rate), by (YYYY-MM-DD), label?} |
channelPriorities | no | array of object (at most 12) | Each item: {channel, label?, reason?}, most important first |
budget | no | object or null | {currency, monthlyTotalCents, channels: [{channel, monthlyCents, targets: [{conversion, cpaCents?, roas?}]}], pacing: {rule: even|front_loaded|monthly_cap, tolerancePct}}; null clears it |
competitors | no | array of object (at most 20) | Each item: the shared competitor record (C6): {id? (slug; generated from the name), name, domains[], handles: {<network>: handle}, adLibraryIds: {metaPageId?, googleAdvertiserId?, microsoftAdvertiserId?}, monitorIds? (context.dev, up to 5), positioningNotes? (up to 3 lines of 240)} (the listening settings' rules); a competitor sent without id, monitorIds or positioningNotes keeps its own (by name). Listening terms, the ad libraries, profile snapshots, GEO prompt sets, the keyword gap and competitor-watch read it |
doNotSay | no | array of string (up to 80 characters) (at most 40) | |
messagingPillars | no | array of object (at most 6) | Each item: {name, message, proofPoints[]} |
Responses
200the record (as GET) plus {changed, fields}400not an object, or no field named403read-only key or no admin scope409version_conflict: expectedVersion is not the current version422invalid_marketing_state: unknown key or invalid field, with issues [{path, message}]
POST /v1/marketing-state/competitors/monitors
C6 (#237) "Watch their pages" (admin, audited): context.dev monitors for each record competitor's home and pricing pages (an existing monitor for the page is used; a new one is tagged with the competitor's id), their ids kept on the record (a new version). At most 10 competitors
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
expectedVersion | no | integer ≥ 0 |
Responses
200the record (as GET) plus {monitors: [{id, name, monitorIds, created, pages, note}], changed}403read-only key or no admin scope409not_configured (no context.dev key), or version_conflict422no_competitors: no record competitor has a domain502monitors_failed: context.dev did not answer
GET /v1/marketing-state/competitors/{id}/battlecard
C6 (#237): one record competitor's battlecard, from the knowledge rows kept under its id: its latest page changes, its ads (running, new this week, the latest), its profile snapshots with the weekly follower change, beside its positioning notes
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{battlecard: {competitorId, name, domains, positioningNotes, changes: [{title, url, importance, at}], ads: {active, newThisWeek, latest}, profiles: [{network, account, followers, followerDelta, windowDays, capturedAt}], rows, updatedAt}}404no competitor with that id on the record
MCP
POST /v1/mcp
MCP streamable HTTP on the official TypeScript SDK (spec 16, E2.6): protocol 2026-07-28 (server/discover + per-request _meta) and stateless 2025-era (initialize); JSON responses only. MCP Apps (io.modelcontextprotocol/ui, W2-6): resources/list and resources/read serve the inbox, weekly-brief and (W5-D4) metrics-pulse widgets (ui://, text/html;profile=mcp-app) at the read scope. W5-D4: prompts/list and prompts/get serve four read-only prompts (triage-approval-inbox, launch-product-campaign, diagnose-paid-efficiency, executive-weekly-brief) hydrated from the tenant's records, and resources/read serves heycmo://state/current and heycmo://brief/latest, all at the read scope
Scope: set per method and tool: read to list and read, the scope of the route a tool calls to act.
Responses
200JSON-RPC result or JSON-RPC error202notification accepted400parse error (-32700), batch or invalid JSON-RPC (-32600), envelope/header mismatch401missing or invalid bearer token (WWW-Authenticate: Bearer realm="heycmo"; with MCP OAuth configured also resource_metadata=<RFC 9728 URL>)403insufficient_scope (OAuth tokens get a WWW-Authenticate step-up challenge naming the heycmo:* scope)405GET/SSE not offered
Mesh
POST /v1/mesh/execute
Execute bounded specialist analysis with pinned skills and a shared context snapshot; return attributed, source-grounded paragraphs and explicit gaps. Optional technical audit reads only the verified site. No external writes. Each call starts a fresh analysis and may incur model costs; retries are not deduplicated.
Scope: execute.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
request | yes | string (at least 1 characters, up to 8000 characters) | |
site | no | string (uri) (up to 2000 characters) |
Responses
200{mode, status: completed|partial|blocked, context, specialists, merged, technicalAudit, gaps, costCents, routeAuditId, auditId}; specialist paragraphs are model-written analysis, not execution receipts. Partial and blocked responses retain explicit evidence gaps.400Invalid input403Execution scope required or workspace restricted
POST /v1/mesh/route
Classify request → specialist delegations (spec 09, planning only)
Scope: read.
Responses
200delegation plan
POST /v1/mesh/council
Run marketing-council recipe (spec 09 AC-3)
Scope: read.
Responses
200council output
POST /v1/mesh/seo-audit
Plan seo-audit fan-out (spec 09 AC-2/AC-5)
Scope: read.
Responses
200fan-out plan
GET /v1/mesh/coordinate
Coordinator's daily plan for the tenant (spec 09 AC-4). Issue #229: the plan the scheduler runs the tenant's day by, recorded once per tenant-local day (the scheduler or this route, whichever asks first) and returned as recorded, with the live state of its deferrals: run order, same-audience conflicts (a deferral proposal only when the move separates the loops), runs moved in from an earlier day and the advisory send budget. date defaults to the workspace's own today; a day not yet begun is a preview (planned: false)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
date | query | no | string (date) |
Responses
200tenantId, runDate, runOrder, conflicts, budget, auditId, planned, plannedAt, movedIn400date is not a calendar day
GET /v1/mesh/specialists
Specialist roster browse (spec 09)
Scope: read.
Responses
200roster
Metrics
GET /v1/metrics
Metric registry: every surfaced number with its definition, unit, source, freshness SLA and trust rule, plus the newest stored value and its freshness/trust
Scope: read.
Responses
200{generatedAt, metrics: [{id, label, definition, unit, window, source, freshnessSlaHours, trustRule, collected, perCurrency, currency, annotated, latest: {date, value, source, observedAt, currency} | null, trust: {status, freshness, warning}}], collection}; money is one metric per currency (<family>.<currency>), never summed across currencies
GET /v1/metrics/pnl
The Marketing P&L over the trailing 30 days, one row per currency (money is never added across currencies): new MRR (stripe.new_mrr_cents_30d), recovered — the email-driven bucket only (recovery.email_driven_cents_30d) — paid spend (paid.spend_cents_30d), HeyCMO's cost (heycmo.cost_cents_30d.usd, on the USD row only: it is metered in US cents), net, a CAC proxy ((paid spend + HeyCMO cost on USD) ÷ new paying subscriptions, organic included) and payback months (CAC proxy ÷ ARPA), both estimates. Each figure cites its registry id; a figure the registry does not hold is null (not measured yet), never 0. The 30 days end today: a figure measured more than 3 days before today is marked stale (its value and date kept) and nothing is derived from it
Scope: read.
Responses
200{generatedAt, windowDays, rows: [{currency, newMrr, recovered, paidSpend, heycmoCost, newPayingSubscriptions, arpa: {metricId, value, date, stale?: true}, net, cacProxy, paybackMonths: {value, from, missing, stale, formula}, notes}], heycmoCost}; a derived value is null when an input is missing or stale
GET /v1/metrics/targets
Each goal of the marketing state against the newest stored value of its own registry metric (same unit and currency by construction): met, ahead or behind by gap in the metric's unit (positive is the good side; a lower-is-better metric reads the other way), not_measured, or not_comparable (a money family without a currency, or an id HeyCMO does not measure), with the days left until its date
Scope: read.
Responses
200{generatedAt, targets: [{goalId, label, metricId, unit, currency, target, by, current: {metricId, value, date}, state, gap, daysLeft, direction, reason}]}; direction is the metric's good direction ('up', 'down' for lower is better, 'neutral'; null for an id HeyCMO does not measure)
GET /v1/metrics/funnel
The funnel: sessions → key events → trials → new paying subscriptions, one row per day between two UTC days (default the 30 days ending today), with how many trials and new paying subscriptions the attribution ledger joined to a visit; the latest complete 30 days as the funnel collector wrote them; and the readiness line "Platform-reported conversions exceed Stripe conversions by N×" when the ad platforms claim at least 10 conversions and twice Stripe's new subscriptions or more
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
from | query | no | string (date) | |
to | query | no | string (date) |
Responses
200{from, to, rows: [{date, sessions, keyEvents, trials, paid, trialsJoined, paidJoined}] (null where a day has no value), latest: {sessions, keyEvents, trials, paid, trialsJoined, paidJoined, platformConversions, stripeNewSubscriptions: {metricId, value, date, trust}}, readiness: [{id, status, ratio, text, metricIds}], metricIds: {rows, latest}}400invalid range
GET /v1/metrics/{id}
One registry metric (an id with a dot): its definition, newest value and trust. A by-channel metric (acquisition.<event>.by_channel: one GA4 key event; acquisition.key_events.by_channel: every key event) also returns its rows per traffic source, medium and campaign with their channel group, and its totals per channel group, summed over a window (default the 28 days ending at its newest stored day); GA4's own session attribution, trusted only after tracking QA
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | |
days | query | no | integer 1–90 | |
from | query | no | string (date) | |
to | query | no | string (date) |
Responses
200{metric, latest, trust}; a by-channel metric adds {from, to, daysWithValues, total, complete, channels: [{source, medium, campaign, channelGroup, value, share}], byGroup: [{channelGroup, value, share}]} — the rows add up to the total; complete is false when GA4 cut its report400invalid range; currency_required (a money family) or event_required (the per-event family acquisition.key_event.by_channel), each with the tenant'sids404unknown metric id
GET /v1/metrics/{id}/series
Daily values of one registry metric between two UTC days (inclusive; default the 30 days ending today, at most 366)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | |
from | query | no | string (date) | |
to | query | no | string (date) |
Responses
200{metric, from, to, points: [{date, value, source, observedAt}], latest, trust}; missing days are absent400invalid range, or currency_required: a money family is one metric per currency (<id>.<currency>), never a sum;idslists the tenant's404unknown metric id
GET /v1/metrics/{id}/annotations
Applied external writes (budget changes, pauses, publishes…) the provider accepted live between two UTC days, on the channels that can move the metric, from the tenant's applied-write ledger; each with a before/after read of the metric labelled for trust and readiness — directional, never causal
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string | |
from | query | no | string (date) | |
to | query | no | string (date) |
Responses
200{metric, from, to, scope: {channels, actionTypePrefixes?} | null, annotations: [{id, date, at, actionType, kind, label, title, channels, provider, externalRef, reversal, beforeAfter: {readiness, label, windowDays, before, after, difference, relativeChange, trust, causal: false, note}, target: {metricId, targeted, why}, readbacks: [{metricId, windowDays, changeDay, readiness, outcome, …}]}], more} — #235 target: the metric the write is meant to move (targeted: it is this chart's); readbacks: its recorded 14- and 28-day reads400invalid range, or currency_required for a bare money family404unknown metric id
GET /v1/metrics/recovery
Recovered revenue attribution
Scope: read.
Responses
200recoveredCents30d … byClass; byStage (issue #232: notice, reminder, urgent, final, unrecorded — the stage of the credited send, per currency); liftOverStripe (recovery beyond Stripe Smart Retries, and the baseline rate from a dunning holdout or "not measured yet"); metricIds maps each number to its registry id
GET /v1/metrics/recovery/waterfall
W5-C4 (#284): the recovery waterfall of the failed invoices whose first failure fell in the last 30 or 90 days, each placed once, per currency (never added across currencies): failed (by decline category: expired_card, authentication_required, insufficient_funds, hard_decline, soft_decline, not_read); held with no email on purpose (a dunning holdout, the customer cancelled, Stripe ended the subscription); not emailed (paid before an email, waiting); emailed at least once live (by the furthest touch: notice, reminder, urgent, final); after the email: recovered and credited (Stripe's Smart Retries, cannot tell, beyond Smart Retries), paid but not credited, not recovered. Amounts are each invoice's amount still owed as last read, except what was recovered after an email: that is the payment credited to it (recovery_attribution, Stripe's amount_paid), with afterEmail.paidDifference the signed difference from what those invoices still owed; invoices with no amount are counted in withoutAmount, never as zero. The cohort is the payments that first failed in the window, not the payments made in it (GET /v1/metrics/recovery counts those), so the two figures can differ. The payback is the beyond-Smart-Retries money over the plan's published price for the same days, US dollars only; when the plan's cost cannot be known the payload says why instead of a number. latestRun: the latest full daily run's counts of invoices it left alone (paused or written off, left to Stripe's retries, final waiting for a scheduled retry)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
days | query | no | one of 30, 90 |
Responses
200{generatedAt, window: {days, from, to}, status: ready | no_failures | no_stripe, invoices, truncated, currencies: [{currency, failed: {invoices, cents, byDecline}, held: {invoices, cents, byReason: {holdout, customer_cancelled, stripe_ended}}, notEmailed: {…, byReason: {paid_before_email, waiting}}, emailed: {…, byTouch: {notice, reminder, urgent, final, unrecorded}}, afterEmail: {recovered, byClass: {smart_retries, unattributed, beyond_smart_retries}, paidDifference, paidNotCredited, notRecovered}, payback: {status: computed | unknown_plan_cost | other_currency, multiple, netCents, planCostCents, reason?}}], withoutAmount: {invoices, emailed, recovered}, latestRun: {at, pausedOrWrittenOff, leftToSmartRetries, finalWaitingForRetry} | null, preDunningSent, plan: {known: true, plan, interval, monthlyCents, currency, trialing} | {known: false, reason}}400days is not 30 or 90
GET /v1/metrics/attribution/models
W5-C4 (#284): one conversion's credit by channel group from the attribution ledger, read by every model side by side: first_touch, last_touch, u_shaped (40/20/40), time_decay (7-day half-life), shapley and markov (removal effect), each over the ledger's first and latest touch of every joined conversion (first, last, Shapley and Markov through W5-C2's compareAttributionModels); and mmm_marginal_roas from the marketing-mix fit the newest weekly review stored with its brief (W5-C2): each paid channel's contribution over the fit's own window and the cost of its next conversion with its 80% interval (the inverse of marginal ROAS), plus the model's baseline — unavailable, with the reason and no number, without a fit, with one that had too little data, with one whose own evidence test says the channels' spend explains the conversions no better than the weekday pattern and chance (fit.evidence.shown false, or no evidence stored), with any outcome but fitted, or with one fitted on the other conversion. No journey carries a value: every model credits conversions. Conversions no visit could be tied to are counted (noVisitor, noTouch), never spread. For new paying subscriptions each channel is also credited with first payments per currency (recorded since migration 20261122100000; older rows are counted as not recorded, never zero)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
conversion | query | no | one of "trials_started", "paid_conversions" | |
days | query | no | integer 1–365 default 90 |
Responses
200{generatedAt, conversion, window: {days, from, to}, status: ready | no_conversions | no_endpoint, conversions: {total, joined, noVisitor, noTouch}, firstPayments: {recorded, notRecorded, byCurrency: [{currency, minor}]} | null, truncated, models: [{id, label, what, available, unavailableReason, channels: [{channelGroup, credit, share, firstPayments: [{currency, minor}], rows?: [{source, medium, credit, share}]}], mmm?: {fittedAt, kpi, currency, window: {from, to, days}, conversions, rSquared, rSquaredBaseline, baseline, channels: [{channel, name, contribution, contributionInterval, marginalCpa, marginalCpaInterval, currentDailySpend}]}}]}400unknown conversion, or days outside 1..365
GET /v1/metrics/alpha
Compact alpha decision and recovery scorecard
Scope: read.
Responses
200source-labelled alpha metrics; unknown values are null; metricIds maps each number to its registry id
Onboarding
GET /v1/onboarding
Onboarding readiness probes: the setup wizard and Settings checklist (Wave 9, E1.1)
Scope: read.
Responses
200secrets/webhooks/sender/billing (plan, trial)/dunning/DRY_RUN/realDataLoops; productContext (one exists) and productContextDescribed (it describes the product: a model drafted it or a person wrote in it, not the blank template), which is what makes "Your website" done; brandKit (one exists) and brandKitDescribed (it has something in it: a colour, a font, a logo or a voice, not the empty kit a template draft stores), which is what makes "Your brand" done
POST /v1/onboarding/bootstrap
Automatic knowledge bootstrap {siteUrl, competitors?} — honest skipped[]. W5-A4: with or without context.dev it also reads the site's robots.txt, homepage and /llms.txt and stages one AI search check (geo_citability_audit: AI bots robots.txt lets in, the homepage on the GEO citability checklist, a drafted /llms.txt when the site has none)
Scope: admin.
Responses
200ok, sitePages, monitors, brandStaged, geoStaged + skipped steps
Operation runs
GET /v1/operation-runs
List this workspace’s persisted operation runs, newest first (read scope)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
limit | query | no | integer 1–100 default 20 |
Responses
200{runs, count}: only this workspace’s runs400Invalid limit
POST /v1/operation-runs
Persist a validated, idempotent operation plan; does not execute it (execute scope)
Scope: execute.
Request body (application/json, required)
See the summary.
Responses
201{run}: the persisted operation run, with bounded, secret-filtered response content400Invalid plan or input, unknown/unimplemented operation, forbidden fields or secret material403Insufficient scope, read-only identity or restricted workspace409The idempotency key belongs to a different plan
GET /v1/operation-runs/{runId}
Read one persisted run from this workspace (read scope)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string |
Responses
200{run}: the persisted operation run, with bounded, secret-filtered response content404Operation run not found, including another workspace’s run
POST /v1/operation-runs/{runId}/advance
Advance at most four steps of a leased operation run, with a 25-second request budget (execute scope)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
maxSteps | no | integer 1–4 default 1 |
Responses
200{run}: the persisted operation run, with bounded, secret-filtered response content400Only maxSteps is accepted; no caller evidence, approval proof, output, or tenant identity403Insufficient scope, read-only identity or restricted workspace404Operation run not found, including another workspace’s run409Another request holds the lease, or the persisted execution contract changed
GET /v1/operation-runs/{runId}/steps/{stepId}/policy-review
Read a v1 fail-closed policy request (read scope)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string | |
stepId | path | yes | string | |
reviewId | query | no | string (uuid) |
Responses
200{review}: exact stored scope, status and expiry. V1 never satisfies an applicable policy. V2 includes the unfiltered stored numeric proposal, exact OAuth account/connection, versioned review material, its hash, and append-only human decision events. Only the three qualified Google Ads reads can be reviewed; this is not legal or live-target validation.400Invalid identifiers or closed request body403Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person409Stale, mismatched, expired, ineligible or already decided scope
POST /v1/operation-runs/{runId}/steps/{stepId}/policy-review
Record a v1 request, never an attestation (execute scope)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string | |
stepId | path | yes | string |
Request body (application/json, required)
object
Responses
200{review}: exact stored scope, status and expiry. V1 never satisfies an applicable policy. V2 includes the unfiltered stored numeric proposal, exact OAuth account/connection, versioned review material, its hash, and append-only human decision events. Only the three qualified Google Ads reads can be reviewed; this is not legal or live-target validation.400Invalid identifiers or closed request body403Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person409Stale, mismatched, expired, ineligible or already decided scope
POST /v1/operation-runs/{runId}/steps/{stepId}/policy-review/{reviewId}/decision
Record a v1 negative decision by a signed-in person (approve scope)
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string | |
stepId | path | yes | string | |
reviewId | path | yes | string (uuid) |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
decision | yes | one of "declined", "revoked" | |
reviewHash | yes | string |
Responses
200{review}: exact stored scope, status and expiry. V1 never satisfies an applicable policy. V2 includes the unfiltered stored numeric proposal, exact OAuth account/connection, versioned review material, its hash, and append-only human decision events. Only the three qualified Google Ads reads can be reviewed; this is not legal or live-target validation.400Invalid identifiers or closed request body403Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person409Stale, mismatched, expired, ineligible or already decided scope
GET /v1/operation-runs/{runId}/steps/{stepId}/privacy-review
Read the exact v2 human privacy review material (read scope)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string | |
stepId | path | yes | string | |
reviewId | query | no | string (uuid) |
Responses
200{review}: exact stored scope, status and expiry. V1 never satisfies an applicable policy. V2 includes the unfiltered stored numeric proposal, exact OAuth account/connection, versioned review material, its hash, and append-only human decision events. Only the three qualified Google Ads reads can be reviewed; this is not legal or live-target validation.400Invalid identifiers or closed request body403Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person409Stale, mismatched, expired, ineligible or already decided scope
POST /v1/operation-runs/{runId}/steps/{stepId}/privacy-review
Record an expiring resource-bound privacy request, without granting permission (execute scope)
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string | |
stepId | path | yes | string |
Request body (application/json, required)
object
Responses
200{review}: exact stored scope, status and expiry. V1 never satisfies an applicable policy. V2 includes the unfiltered stored numeric proposal, exact OAuth account/connection, versioned review material, its hash, and append-only human decision events. Only the three qualified Google Ads reads can be reviewed; this is not legal or live-target validation.400Invalid identifiers or closed request body403Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person409Stale, mismatched, expired, ineligible or already decided scope
POST /v1/operation-runs/{runId}/steps/{stepId}/privacy-review/{reviewId}/decision
Explicitly review, decline or revoke the displayed privacy scope as a Clerk/WorkOS person (approve scope)
API keys, CLI and MCP identities cannot decide, even without sign-in configured. Reviewer identity comes only from trusted authentication context. No input, policy list, resource override, approved boolean or arbitrary evidence is accepted. A reviewed event authorizes only this exact expiring workspace privacy scope; it never opens a Google Ads write guard.
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
runId | path | yes | string | |
stepId | path | yes | string | |
reviewId | path | yes | string (uuid) |
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
decision | yes | one of "reviewed", "declined", "revoked" | |
reviewHash | yes | string | |
reviewMaterialHash | yes | string | |
acknowledgementVersion | yes | must be "operation-privacy-ack/v1" |
Responses
200{review}: exact stored scope, status and expiry. V1 never satisfies an applicable policy. V2 includes the unfiltered stored numeric proposal, exact OAuth account/connection, versioned review material, its hash, and append-only human decision events. Only the three qualified Google Ads reads can be reviewed; this is not legal or live-target validation.400Invalid identifiers or closed request body403Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person409Stale, mismatched, expired, ineligible or already decided scope
Operations
GET /v1/operations
Reviewed source operations and this server’s exact binding availability, not provider certification (read scope)
Scope: read.
Responses
200{sourcePin, catalogHash, coverage, reviewedShards, operations}: definitions, implemented, bindingVersion, availability, inputSchema, outputSchema; partial coverage publishes no global operation total
GET /v1/operations/{operationId}
Inspect one reviewed operation and its registered binding contracts (read scope)
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
operationId | path | yes | string |
Responses
200{operation}: the definition and binding availability404Operation not found
Packs
POST /v1/packs/{id}/enable
Turn a skill pack on for this workspace (admin; B6). A ready public launch or curated pack is enabled directly at the platform revision, with an audit row; a pack already on is left alone. Anything else answers 409 and may be requested (enable-request)
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{status: enabled|already_enabled, enabled, revision, warnings}404unknown pack (another tenant's private pack reads the same)409{error, code: pack_not_ready|pack_needs_review|pack_turned_off, blockedBy, canRequest, request}
POST /v1/packs/{id}/enable-request
Ask the operator to turn a skill pack on for this workspace (admin; B6): queues a first-pin request that the isolated reviewer approves and the activation credential processes. Nothing changes until then; a request already waiting is returned
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{requestId, status: waiting_approval, revision, alreadyQueued: true}202{requestId, status: waiting_approval, revision, alreadyQueued: false}404unknown pack409already enabled, or turned off by the operator
Platform
GET /v1/platform/killswitches
Platform kill switches and recent history (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{switches, history}403not the operator tenant admin
POST /v1/platform/killswitches
Engage/release a global, channel or tool platform kill switch (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
kind | yes | one of "global", "channel", "tool" | |
target | no | string | channel name or governed tool name; omitted for global |
enabled | yes | boolean | |
reason | no | string (up to 500 characters) |
Responses
200updated row400bad kind/target403not the operator tenant admin
GET /v1/platform/live-send
Live-send requests and recent transitions across tenants (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{dbApprovalsEnabled, tenants: [{tenantId, name, slug, status, state, requestedAt, requestedBy, termsVersion, notifiedAt, trialPausedAt, decidedAt, decidedBy, reason}], history, sla} (W4-D2: notifiedAt is when the operator tenant was told of the request; trialPausedAt, when the request stopped the tenant's trial clock)403not the operator tenant admin
POST /v1/platform/live-send/{tenantId}/approve
Approve a requested go-live with a reason (operator tenant admin only; refused while HEYCMO_LIVE_SEND_DB=0). W4-D2: the tenant's trial clock starts again, its end moved on by the time it waited, and the tenant is told live sending is on
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
reason | yes | string (up to 500 characters) |
Responses
200state approved403not the operator tenant admin409not requested, preconditions unmet, or DB approvals disabled
POST /v1/platform/live-send/{tenantId}/revoke
Revoke a tenant's live send (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200state revoked403not the operator tenant admin
GET /v1/platform/autonomy
Autonomy threshold sets, requests, grants and recent transitions across tenants (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{enabled, current, thresholdSets, grants, history}403not the operator tenant admin
POST /v1/platform/autonomy/thresholds
Propose a new, unsigned threshold set version (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
201proposed set with its hash400rules invalid or below the floors403not the operator tenant admin
POST /v1/platform/autonomy/thresholds/{id}/sign
Sign a threshold set: the operator confirms the exact hash it reviewed. Demotes grants issued under the previous signature
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200signed-current403not the operator tenant admin409hash mismatch, already signed, or older than the signed version
POST /v1/platform/autonomy/{tenantId}/approve
Approve a tenant's autonomy request with a reason (operator tenant admin only); re-checks plan, eligibility and the signed thresholds
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200state autonomous403not the operator tenant admin409not requested, not eligible, or thresholds unsigned
POST /v1/platform/autonomy/{tenantId}/revoke
End a tenant's autonomy request or grant (operator tenant admin only); the tenant is notified
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200{state: gated, changed}403not the operator tenant admin
GET /v1/platform/write-channels
Write-channel requests and recent transitions across tenants (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{dbApprovalsEnabled, channels: [{tenantId, name, slug, status, channel, state, requestedAt, requestedBy, termsVersion, notifiedAt (W4-D2: when the operator tenant was told of the request), decidedAt, decidedBy, reason}], history}403not the operator tenant admin
POST /v1/platform/write-channels/{tenantId}/{channel}/approve
Approve a requested write channel with a reason (operator tenant admin only; refused while HEYCMO_WRITE_CHANNELS_DB is off)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string | |
channel | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
reason | yes | string (up to 500 characters) |
Responses
200state approved403not the operator tenant admin409not requested, readiness unmet, or DB approvals disabled
POST /v1/platform/write-channels/{tenantId}/{channel}/revoke
Revoke a tenant's write channel (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string | |
channel | path | yes | string |
Responses
200state revoked403not the operator tenant admin
GET /v1/platform/email-sends
Email attempts whose provider outcome is unknown (operator tenant admin only); no recipient or message content is returned
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{sends: [{tenantId, tenantName, actionId, actionType, loop, provider, dryRun, idempotencyKey, attemptedAt, unknownSince, statusReason}]}403not the operator tenant admin
POST /v1/platform/email-sends/{tenantId}/{actionId}/resolve
Record verified acceptance or confirmed non-acceptance of an uncertain live email attempt; never sends another email (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string (uuid) | |
actionId | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
outcome | yes | one of "accepted", "confirmed_not_accepted" | |
provider | yes | one of "resend", "postmark", "ses", "sendgrid" | |
externalRef | no | string (at least 1 characters, up to 512 characters) | required for accepted: the provider message id |
acceptedAt | no | string (date-time) | required for accepted: acceptance time at or after the original attempt and not in the future |
dryRun | yes | boolean | the posture of the original attempt. accepted requires false: this operator path does not reclassify a rehearsal as live. confirmed_not_accepted takes either, matching the attempt |
reason | yes | string (at least 1 characters, up to 500 characters) |
Responses
200{result: finalized | accepted_pending_finalization | reset_for_retry | already_reconciled}400invalid or inconsistent evidence403not the operator tenant admin404malformed tenant or action id409the action is not reconcilable, or the evidence conflicts with its original attempt
GET /v1/platform/external-writes
External writes whose provider outcome is unknown, across tenants (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{writes}403not the operator tenant admin
POST /v1/platform/external-writes/{tenantId}/{actionId}/resolve
Record what the operator verified at the provider for an outcome-unknown external write (operator tenant admin only; audited)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string | |
actionId | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
outcome | yes | one of "applied", "not_applied" | |
externalRef | no | string (up to 512 characters) | the provider id of the applied write |
reason | yes | string (up to 500 characters) |
Responses
200{result: finalized | accepted_pending_finalization | marked_not_applied}403not the operator tenant admin409the write is not awaiting reconciliation
GET /v1/platform/experiments/pooled
Dunning holdouts pooled across tenants, with a stratified check (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{scope: platform, analysis, allocationsDiffer, stratified, byExperiment}403not the operator tenant admin
GET /v1/platform/marketing-policy
Platform marketing send policy per type and its history (operator tenant admin only)
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Responses
200{ceiling, types, history}403not the operator tenant admin
PUT /v1/platform/marketing-policy/{actionType}
Allow or block one marketing send type for every tenant, with its per-recipient frequency cap (operator tenant admin only). HEYCMO_ALLOW_MARKETING_SENDS stays the global ceiling
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
actionType | path | yes | string |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
status | yes | one of "allowed", "blocked" | |
maxPerRecipient | no | integer 1–10 | |
windowDays | no | integer 1–90 | |
reason | yes | string (up to 500 characters) |
Responses
200{actionType, platform, ceiling}403not the operator tenant admin
GET /v1/platform/caps/{tenantId}
A2 (operator tenant admin only): a tenant's caps as GET /v1/tenant/caps shows them
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200as GET /v1/tenant/caps403operator_only / operator_tenant_unconfigured404tenant not found
PUT /v1/platform/caps/{tenantId}
A2 (operator tenant admin only): set a tenant's ceilings — sendPerDay, changePerRun, massAudienceThreshold, llmDailyCents, llmWeeklyCents (0 or null = the plan default), alwaysEscalateExtra — with a reason (audited caps_ceiling_set). Spend ceilings per currency: PUT /v1/platform/spend-caps/{tenantId}/{currency}
Scope: admin. HeyCMO operator only (an admin key of the operator workspace).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200as GET /v1/tenant/caps400invalid_value, unknown_field, invalid_reason, nothing_to_change403operator_only / operator_tenant_unconfigured404tenant not found
Playbooks
GET /v1/playbooks
List playbooks (list_playbooks)
Scope: read.
Responses
200registry
POST /v1/playbooks/run
Start playbook (run_playbook)
Scope: execute.
Responses
200episode started or suspended503a step's draft refused for a reason asking again meets again, {code, error} in plain words: no_model (no model provider is configured; a new episode is not started), budget (the workspace's model budget is used up), blocked_input (an input read like an instruction) or model_refused (the model declined)
Product analytics
PUT /v1/product-analytics/settings
Name the events that mean active, activated and trial started, and the connected source the daily metric collection reads (admin, audited)
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
provider | no | one of "posthog", "mixpanel", "amplitude" | |
events | no | object |
|
Responses
200{ok, provider, events, collecting}400malformed body or event name409the chosen provider is not connected
Push
GET /v1/push
Web Push for the installed dashboard (W2-7): whether it is configured, the VAPID public key browsers subscribe with, the per-person device limit, and the caller's devices (null for an API key; endpoints and keys are never returned)
Scope: read.
Responses
200{configured, publicKey, limit, you: {devices: [{id, endpointHash, createdAt, lastSentAt, lastFailedAt, lastError}]} | null}
POST /v1/push/subscriptions
Register this device for approval pushes (a signed-in person, Clerk session; audited). The body is PushSubscription.toJSON(); the endpoint must be https on a public push-service host
Scope: approve.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
endpoint | yes | string (uri) (up to 2048 characters) | |
keys | yes | object |
|
Responses
200this device was already registered (keys refreshed, or moved to you)201registered; the view plus subscriptionId400not a valid push subscription403an API key, a read-only session or an inactive membership409the per-person device limit is reached503push is not configured on this deployment
DELETE /v1/push/subscriptions/{id}
Remove one of your devices (a signed-in person; audited)
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Responses
200{removed}404not one of your devices
POST /v1/push/test
Send a test push to your own devices now (a signed-in person; 3 per 10 minutes)
Scope: approve.
Responses
200{ok, outcome, sent, failed, expired}: delivered to at least one device409suppressed by a kill switch422no device took it429rate limited503push is not configured on this deployment
Reviews
GET /v1/reviews
The collected customer reviews (newest first) with the reply each shows in its store and any reply HeyCMO proposed
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
provider | query | no | one of "g2", "appstore", "googleplay" | |
status | query | no | one of "unanswered", "answered", "all" | |
limit | query | no | integer 1–200 |
Responses
200{reviews: [{id, provider, rating, title, body, author, reply, proposedReplyActionId, flagged, canReply, …}]}
POST /v1/reviews/collect
Read every connected review source now through the governed reads (stopped by the reviews kill switch) and store new reviews
Scope: execute.
Responses
200{sources: [{provider, read, added, updated, flagged}], failures}404no review source connected423the reviews channel is stopped
POST /v1/reviews/{id}/replies
Stage a Tier-2 public reply to one App Store or Google Play review (drafted in the brand voice when reply is omitted); store-policy checked; nothing is posted here
Scope: execute.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
reply | no | string | |
reasoning | no | string (up to 1000 characters) |
Responses
201{stagedActionId, preview, posture, …}400the reply was refused (incentive, a request to change the review, too long, markup)404no such review422G2 is read only, or no compliant reply could be drafted
SEO
GET /v1/seo/readiness
SEO pack readiness for this tenant (E3.1): Search Console connection with a verified site, a live Search Console read in the last 7 days, recorded eval gates; plus the verified site and which SEO data sources are configured
Scope: read.
Responses
200{packId, gate, ready, checks: [{id, ok, detail}], site, sources}
POST /v1/seo/readiness/probe
Run one governed live Search Console read (the readiness evidence), then return the readiness checks (execute)
Scope: execute.
Responses
200{probe: {ok, rows?, source?, error?}, …readiness}
GET /v1/seo/data-budget
Month-to-date DataForSEO spend against this tenant's monthly SEO data budget (E3.2)
Scope: read.
Responses
200{provider, configured, problem?, monthlyBudgetCents, source, spentMicroUsd, spentCents, remainingCents, calls}
PUT /v1/seo/data-budget
Set this tenant's monthly DataForSEO budget (admin; audited)
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
monthlyBudgetCents | yes | integer 0–10000000 |
Responses
200the budget view400invalid body
POST /v1/seo/audit
Run the technical SEO audit now (#233, execute): a governed crawl of the verified site (50 pages, 45 seconds), Core Web Vitals from CrUX and a PageSpeed Insights lab run when the Google API key is configured; stages one seo_technical_audit report (Tier 1) under seo-drift. One per tenant per UTC hour: a second request answers the card already staged without crawling; the audit holds the tenant before crawling, so a request while one runs is 409 in_progress and crawls nothing
Scope: execute.
Responses
200this hour's audit was already staged: {stagedActionId, alreadyStaged: true, crawled: false, summary}201{stagedActionId, alreadyStaged: false, crawled: true, summary: {pagesCrawled, healthScore, errors, warnings, notices, stoppedBy, coreWebVitals: measured | no_field_data | not_configured | unavailable}}403read-only key409no_site — no verified site to crawl; in_progress — an audit of the site is already running (nothing crawled)502crawl_failed — the crawl read no page of the site
Session
GET /v1/session
The dashboard shell in one read: tenant and identity, operator flag, plan and trial, inbox count and send cap, kill switches, sender identity, when the workspace first got value, and the ad networks a person can propose a change for (C3, from stored connections; no ad platform is called)
Scope: read.
Responses
200{tenant, identity, operator, billing, inbox, killSwitch, senderIdentity, features, firstValue, adChanges: {networks: [googleads|microsoftads]}|null}; B6: features = {geo, press, creative, landingPages}, each {available, reason: not_set_up|paid_plan|restricted|null}
Slack
GET /v1/slack
The HeyCMO Slack app (E4.7): whether it is configured and installed, the installed workspace (teamId, teamName), the approval channel, linked members, and whether the caller is linked (null for an API key)
Scope: read.
Responses
200{configured, installed, teamId, teamName, channelId, installedAt, linkedMembers, you: {linked} | null, redirectUri}
PUT /v1/slack/channel
Set the Slack channel approval notices go to (admin). Send the teamId GET /v1/slack returned: when another workspace is installed now, nothing is saved (409). Without teamId the channel is saved for the workspace installed when the request arrives
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
channelId | yes | string or null | a Slack channel id (C… or G…); null clears it |
teamId | no | string | optional: the workspace the channel belongs to, as GET /v1/slack returned it (T… or E…) |
Responses
200the Slack settings as GET /v1/slack returns them400channelId is not a channel id, or teamId is not a workspace id409slack_not_installed, or slack_workspace_changed: the app is installed in another workspace than teamId names, or was reinstalled in another workspace while the request was handled; nothing was saved
Staged
GET /v1/staged/{id}/preview
The email-send action exactly as the send path would hand it to Resend (E3.6): From, subject, HTML and text with the compliance footer, List-Unsubscribe headers, live or rehearsed and why, and what would refuse it
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{exact, from, to, subject, html, text, headers, sendClass, posture, marketingPolicy, refusals}404not this tenant’s action422not an email send
Status
GET /v1/ops/heartbeat
Product health (unauthenticated; 503 when degraded)
No key: anyone can call it.
Responses
200ok503degraded
Strategy
GET /v1/strategy
Strategy plan progress (spec 05): status, version, the 13 sections with their status, the next one to approve. include=bodies adds each section's text, reviewer feedback and decision time, the finalized plan and whether finalize's pack gate is met (the dashboard Strategy page); with include=bodies a workspace without a plan gets 200 and status none
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
include | query | no | one of "bodies" |
Responses
200{status, version, sections: [{seq, title, status}], nextPending}; with include=bodies also sections[].{bodyMd, feedback, decidedAt}, finalPlanMd, packReady, and with no plan yet {status: none, version: 0, sections: [], nextPending: null, finalPlanMd: null, packReady}404no plan yet (without include=bodies)
POST /v1/strategy/sections/{seq}/approve
Approve a drafted section of the plan (approve scope)
Approving a section is a person's decision. Where people sign in (Clerk or AuthKit), only a signed-in session approves; a machine key, an OAuth access token or the CLI changes nothing and gets 403 {code: human_approval_required, approved: false, approveUrl} for a person to open on the dashboard's plan page. Asking for changes (adjust) and reopening stay open to keys.
Scope: approve.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
seq | path | yes | integer ≥ 1 |
Responses
200plan progress after the decision: {status, version, sections, nextPending}400the section is missing or not drafted403read-only key, scope, or human_approval_required (with approveUrl)404no plan yet
POST /v1/strategy/finalize
Finalize the approved plan (admin): compile it, write the marketing state from the approved sections (explicit fields win), stage the loop-activation proposal and one playbook run request per recommended playbook
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
marketingState | no | object | any marketing-state fields (as PUT /v1/marketing-state); each replaces what was read from the sections |
Responses
200{version, stagedActionId, proposal, planChars, marketingState: {version, changed, fields, notes}, playbookRunRequests: [{playbookId, name, reason, stagedActionId}]}400the plan is not ready (a section not approved, wrong status)409marketingskills is not enabled at a ready revision for this tenant422invalid marketingState fields (issues)
Subjects
POST /v1/subjects/erase
A4 (#225): erase one end customer (admin; 10 a minute). Body {email}. Removes the address from staged actions (a waiting draft gets a new version, so an approval of the old one no longer holds, and a send without its recipient is refused; decided ones keep their hashes; approved or executing ones are left and counted), journalist requests, product activity (and the captured visits of the visitor it links), the feedback ledger rows and the cached e-mail verification answer of the person (found by their keyed token), sensed data, notification and desk address lists, knowledge and voice text (and the embeddings computed from it), and every other table the tenant data registry has it search (customer reviews, the brand kit, product context, strategy, the marketing state and its history, the editorial calendar, creative corpus, Canva texts, GEO answers, firmographics, founder posts, journalist coverage, hosted test pages, autonomy grants, rejection notes, consent evidence, playbook runs); keeps only a suppression entry holding the address's SHA-256 (the token), so nothing is sent to it again, and what the registry keeps (the audit log and other append-only histories, creative assets, keyed tokens, records keyed by Stripe ids). Audited (subject_erased) with the token, never the address
Scope: admin.
Responses
200{status: "erased", token, suppression: added|kept, stagedActionsRedacted, stagedActionsInFlight, journalistRequestsRedacted, productActivityUsersDeleted, draftFeedbackDeleted, verificationVerdictsDeleted, senseSnapshotsRedacted, senseSnapshotHistoryDeleted, recipientListsUpdated, knowledgeRowsRedacted, conversionTouchesDeleted, tables: {redacted: {<table>: rows}, deleted: {<table>: rows}}, complete}400invalid_email: the body must be {"email": "<one e-mail address>"}403read-only key or no admin scope429too many erasures (retry-after)
Suppressions
GET /v1/suppressions
The do-not-contact list, counted: every address on it by why (unsubscribed, bounced, complained, added by a person, erased, not a working address), and how many were added in the last seven days and from where. Addresses are stored hashed and never returned
Scope: read.
Responses
200{ total, byReason: { unsubscribed, bounced, complained, yours, erased, invalid, other }, addedThisWeek, sourcesThisWeek: [unsubscribe_link|resend|postmark|ses|sendgrid|team|erasure|address_check|other] }
POST /v1/suppressions
Add an address to the do-not-contact list (admin): nobody at it gets an email from HeyCMO. An address already listed is kept as it is. The address is hashed, never stored, logged or audited; the addition is audited as suppression_added
Scope: admin.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
address | yes | string |
Responses
200{ added: false, reason, source, addedAt }: already listed201{ added: true }400not one email address403admin scope required, or a read-only key
POST /v1/suppressions/lookup
Is this address on the do-not-contact list, and why. The address is hashed and compared, never stored or logged
Scope: read.
Request body (application/json, required)
| Field | Required | Type | Description |
|---|---|---|---|
address | yes | string |
Responses
200{ listed: false } | { listed: true, reason, source, addedAt }400not one email address
Tenant
PUT /v1/tenant/secrets/{key}
Store per-tenant integration secret (write-only). {value, switchProvider?}: an affiliate platform key (rewardful, partnerstack, firstpromoter, tolt, dub) replaces another platform's only when switchProvider names the platform connected now (W3-6)
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | path | yes | string |
Responses
200{ok, key, version}; replaced: the affiliate platforms whose key a switch deleted409affiliate_switch_required — another affiliate platform (currentProvider) is connected and switchProvider did not name it; nothing was changed. sendgrid_key_replaced — another SendGrid API key was stored while a sendgrid_webhook_key was saved; it was not kept422unknown key, or a value of the wrong shape for that key
GET /v1/tenant/live-send
Live-send lifecycle (E1.5): state, effective send posture, configuration preconditions, recovery rehearsal evidence, terms, history
Scope: read.
Responses
200{state, posture, request: {requestedAt, requestedBy, termsVersion, notifiedAt (W4-D2: when HeyCMO's operator was told of it)} | null, decision, reason, preconditions, wedge (independent recovery evidence checks and the approved dry-run action id), terms, sla: {businessDays, text} (W4-D2: how soon HeyCMO decides; null where no operator can approve), trial (the no-card trial, as GET /v1/billing/status; null when not on one), canRequest, canRevoke, history}
POST /v1/tenant/live-send/request
Request go-live (admin). Needs every configuration precondition and the recovery rehearsal wedge, plus the current terms version; the operator approves. W4-D2: the operator tenant is notified, and a running no-card trial's clock stops until the request is approved or revoked
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
acknowledgeTerms | yes | must be true | |
termsVersion | yes | string | |
reason | no | string (up to 500 characters) |
Responses
200state requested400terms not acknowledged409preconditions_unmet or wedge_evidence_required (missing recovery rehearsal evidence), or not requestable from the current state
POST /v1/tenant/live-send/revoke
Revoke live send (admin); applies to the next send and overrides the env allowlist
Scope: admin.
Responses
200state revoked409already revoked
GET /v1/tenant/write-channels
Write-channel enablement (E4.0): per channel state, posture (dry run unless operator-approved), readiness, terms, history
Scope: read.
Responses
200{channels: [{channel, state, posture, adapters, readiness, request: {requestedAt, requestedBy, termsVersion, notifiedAt (W4-D2: when HeyCMO's operator was told of it)} | null, decision, reason, canRequest, canRevoke}], terms, dbApprovalsEnabled, history}
POST /v1/tenant/write-channels/{channel}/request
Request a write channel go live (admin). Needs every readiness check and the current terms; the operator approves (W4-D2: the operator tenant is notified)
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
channel | path | yes | one of "ads", "cms", "indexnow", "crm", "social", "esp", "reviews" |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
acknowledgeTerms | yes | must be true | |
termsVersion | yes | string | |
reason | no | string (up to 500 characters) |
Responses
200state requested400terms not acknowledged404no adapter acts in this channel409readiness unmet or not requestable from the current state
POST /v1/tenant/write-channels/{channel}/revoke
Revoke a write channel (admin); the next write is a dry run
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
channel | path | yes | string |
Responses
200state revoked409already revoked
GET /v1/tenant/ad-political-authorizations
W3-2: the tenant's political / social-issue ad authorisations per platform and ad account (an ad creative swap of such an ad names one)
Scope: read.
Responses
200{platforms, authorizations: [{id, platform, adAccount, reference, paidForBy, recordedAt, revokedAt, ref}]}
PUT /v1/tenant/ad-political-authorizations
Record the tenant's authorisation to run political or social-issue ads on one Google Ads or Meta ad account (admin); revokes that account's earlier one
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
platform | yes | one of "googleads", "meta" | |
adAccount | yes | string | Google Ads customer id (10 digits) or Meta ad account id (act_ optional) |
reference | yes | string (up to 200 characters) | The platform's reference for the authorisation |
paidForBy | yes | string (up to 200 characters) | The "Paid for by" disclaimer |
Responses
200{authorization}400invalid platform, ad account or text409another authorisation for the account was recorded at the same time (code conflict); read them again
POST /v1/tenant/ad-political-authorizations/{id}/revoke
Revoke a political / social-issue ad authorisation (admin); swaps that name it are refused from then on
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string |
Responses
200{authorization}404no such authorisation
GET /v1/tenant/notifications
Approval-notification settings (admin): recipients, coalescing window, Slack configured, last outcome
Scope: admin.
Responses
200settings (the Slack URL is never returned)
PUT /v1/tenant/notifications
Set approval-notification recipients, window, or the Slack incoming webhook (admin)
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
emailRecipients | no | array of string (email) (at most 10) | |
coalesceMinutes | no | integer 1–1440 | |
slackWebhookUrl | no | string or null | https://hooks.slack.com/services/…; null removes it |
Responses
200updated settings400invalid input
POST /v1/tenant/notifications/test
Send a test approval notification to the configured channels now (admin)
Scope: admin.
Responses
200delivered to at least one channel409suppressed by a kill switch422no channel delivered
GET /v1/tenant/marketing-policy
Marketing send policy per type (E3.9): the platform decision and frequency cap, this workspace’s opt-in and consent basis, the evidence the send gate needs, and whether a send would leave dry-run
Scope: read.
Responses
200{ceiling, posture, evidence, consentBases, types, history}
PUT /v1/tenant/marketing-policy/{actionType}
Opt in or out of one marketing send type (admin). Opting in needs the consent basis for that recipient list and where its evidence lives
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
actionType | path | yes | one of "winback_send", "churn_intervention_send", "trial_conversion_send", "pql_upgrade_send", "expansion_upsell_send" |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
enabled | yes | boolean | |
consentBasis | no | one of "express_consent", "soft_opt_in", "legitimate_interest" | |
consentEvidence | no | string (up to 500 characters) | where the consent or balancing-test record lives |
maxPerRecipient | no | integer or null 1–10 | only ever tightens the platform cap |
windowDays | no | integer or null 1–90 | |
ctaUrl | no | string or null | https page the email’s one button opens |
Responses
200{actionType, tenant, platform, reasons}400invalid input404unknown marketing send type
GET /v1/tenant/dunning-config
Current failed-payment recovery config and touch plan (issue #232): the stored config, the defaults, and touchPlan — the notice, reminder, urgent and final touches with their days and template subjects, includeRetrying, pre-dunning, the 3-day spacing, declineTiming (W5-A2: how why the payment failed changes the timing — the soft-decline grace hours, the insufficient-funds payday wait, the hold before Stripe's next retry and how many days after a touch is due it lasts, the reminder day of a decline only the customer can fix), the sense window and whether Stripe's invoice.payment_failed webhook has reached this workspace
Scope: read.
Responses
200dunningConfig, defaults, touchPlan
PUT /v1/tenant/dunning-config
Set failed-payment recovery config (replaces it): includeRetrying, sinceDays (1-90), maxInvoicesPerRun (1-2000), reminderDay, urgentDay, finalDay (reminder from day 3 to 14, each gap at least 3 days, final at most day 45), preDunning, vipThresholdCents (1-1000000000000 in the smallest unit of vipThresholdCurrency, default 50000) and vipThresholdCurrency (a three-letter ISO 4217 code, default usd): a failed invoice in that currency of at least that amount is a VIP account's, with no exchange rates (W5-A3). A touch-plan change demotes an autonomy grant for the payment-recovery send at its next use
Scope: admin.
Responses
200updated400invalid value or touch plan422unknown field
GET /v1/tenant/caps
A2: every cap of this workspace — its ceiling (set by the HeyCMO operator, or the default: 25 sends a day, 10 changes a run, 100 recipients, the plan's model-spend ceiling, the operator's spend caps per currency), the workspace's own value, the value that applies (the tighter) and today's usage
Scope: read.
Responses
200{tenantId, plan, caps: {sendPerDay, changePerRun, massAudienceThreshold, llmDailyCents, llmWeeklyCents: {ceiling, value, effective, usedToday?, ceilingSource?}}, spendCaps: [{currency, ceiling, value, effective, spent}], alwaysEscalate: {builtin, operator, tenant}}
PUT /v1/tenant/caps
A2: lower this workspace's caps (admin; audited caps_set). Body: sendPerDay, changePerRun, massAudienceThreshold, llmDailyCents, llmWeeklyCents (integers; null removes the value), spendCaps {<currency>: {daily, weekly} as decimal strings, or null}, alwaysEscalateExtra (conditions to add, or null), reason. Tighten only: a value above its ceiling is refused
Scope: admin.
Responses
200the updated caps (as GET)400above_ceiling (with field and ceiling), no_ceiling (a currency the operator capped nowhere), invalid_value, unknown_field, nothing_to_change403read-only key or no admin scope
POST /v1/tenant/export
A4 (#225): export this workspace's data (admin; refused for a read-only key) as a JSON attachment: every table of its data the tenant data registry exports (all but secrets, one-time credentials, tables of nothing but keyed tokens, pure system state and operator records), 10,000 rows each, newest first, read in one snapshot — customer reviews, knowledge and voice text, the brand kit, strategy, memberships and billing records included; never secrets, key hashes, push-device keys, the experiment salt, click ids, vector embeddings or a keyed token of a person (one inside a row reads "[keyed token]")
Scope: admin.
Responses
200{exportVersion: 2, exportedAt, note, truncated: [tables that hit the cap], tenant, tables: {<table>: rows}} (content-disposition: attachment)403read-only key or no admin scope
POST /v1/tenant/delete
A4 (#225): delete this workspace (admin). Body {confirm: "<tenantId>"}. Marks it deleted and engages the kill switch at once (every later request is refused) and revokes every key, then deletes or redacts every table of its data as the data registry says (the audit log, billing and tax records and the append-only histories are kept, named in retained with the reason) and its stored creative asset files
Scope: admin.
Responses
200{status: "deleted", keysRevoked, secretsDeleted, …Purged counts per table (customerReviewsPurged, knowledgeChunksPurged, voiceSnippetsPurged, creativeAssetsPurged, listeningCollectionsPurged, metricDaysPurged, companyFirmographicsPurged among them), hostedDnsCleanup?, oauthConnectionsNotDeleted, tables: {deleted: {<table>: rows}, redacted: {<table>: rows}}, assetFiles: {status: purged|failed|not_configured, deleted?, prefix?}, retained: [tables whose rows stay], retainedReasons: {<table>: why}}400confirmation required: the body must be {"confirm": "<tenantId>"}403read-only key or no admin scope409this tenant owns the deployment-level Stripe webhook (HEYCMO_OPERATOR_TENANT_ID)500tenant_purge_incomplete: the workspace is deleted and its keys revoked, but the registry purge failed and was rolled back whole; the nightly retention job runs it again
GET /v1/tenant/contact-frequency
How often HeyCMO may email one person, across every loop and both classes of email (issue #229): the setting (custom false while the workspace never saved one: the platform default, once in 7 days), its bounds, the payment-recovery allowance (recovery emails count toward it but are never held back; they stay 3 days apart) and the sentence Settings shows
Scope: read.
Responses
200contactFrequency, defaults, bounds, recoveryAllowance, summary, applies
PUT /v1/tenant/contact-frequency
Save the contact frequency (admin): maxPerWindow (1-10) emails in windowDays (1-90); both null returns to the default. Every email a loop stages is checked over this window, and a saved one is a stricter bound on every marketing type at send time
Scope: admin.
Responses
200updated400not a JSON object403read-only key422value out of bounds, or unknown field
GET /v1/tenant/listening
Listening settings and status (E3.11/E3.12): terms in effect and their exclusion phrases (B2), configured native sources and ad libraries with their coverage, which provider reads each (official, or Scrape Creators) and, when off, the reason (not configured, turned off, or waiting on an agreement or access), month-to-date paid usage against the hard budgets (Scrape Creators credits included), last collection (per source: items read and, B2, off-topic items not kept)
Scope: read.
Responses
200settings, terms, marketingStateCompetitors ({version, competitors: [{name, domains}], match, onlyInMarketingState, onlyInListening}: the marketing state's competitors, which plans and drafts read, against every name monitoring watches here, companies and competitor search terms alike), sources (B6: each withlabel, why it is off in plain words), adLibraries, budgets, lastCollectedAt (B6: the newest finished collection, null for never), collection (B6: with nextSlotAt)
PUT /v1/tenant/listening
Set listening settings (admin): enabled, brandTerms, competitorTerms, competitors (company ad-library ids), disabledSources, xMonthlyBudgetCents (can only lower the platform ceiling), termExclusions (B2: phrases per term that stop a mention matching it; a collected item must name a term as a whole word; at most 16,384 bytes stored, else 400 with the size)
Scope: admin.
Responses
200updated: {ok, settings, terms, marketingStateCompetitors, xMonthlyBudgetCents}400invalid or unknown field
POST /v1/tenant/listening/collect
Collect mentions for this workspace now (B6): one collection, bounded like the scheduler's: at most one per workspace and collection slot (a second call answers already_collected), the same gates (kill switch, suspension, a listening loop on, listening on, terms) and the same hard monthly budgets and governed reads. Run now on an enabled listening loop does the same first
Scope: execute.
Responses
200{status: complete|failed|exhausted|already_collected|in_flight|skipped, reason, slot, nextSlotAt, collected (new mentions), total, sources: [{source, status, kept, offTopic}], lastCollectedAt}403read-only key
Tenants
GET /v1/tenants/provision
Pre-tenant status for a Clerk session (an AuthKit session with HEYCMO_IDENTITY_PROVIDER=workos): is the active organization bound, and may this caller provision it (dashboard session only; machine keys refused)
A signed-in dashboard session; API keys are not accepted.
Responses
200state (unprovisioned|provisioned|deleted), member, canProvision, tenant (members only)401missing or invalid Clerk session403machine key (clerk_session_required) or no active organization (clerk_org_required)429rate limited
POST /v1/tenants/provision
Create the tenant bound to the caller's Clerk organization (WorkOS organization with HEYCMO_IDENTITY_PROVIDER=workos) {name?, timezone?} — org admin only, idempotent for the founding admin; starts the no-card Operator trial (HEYCMO_TRIAL_DAYS, default 14)
A signed-in dashboard session; API keys are not accepted.
Responses
200already provisioned by this caller (created: false)201tenant created (created: true)400invalid name or timezone401missing or invalid Clerk session403clerk_session_required | clerk_org_required | org_admin_required (also: a signed Clerk or WorkOS webhook already gave the caller a role other than admin) | membership_inactive (a signed Clerk or WorkOS webhook already removed, or WorkOS deactivated, the caller)409org_already_bound | org_tenant_deleted | org_deleted (AuthKit: a WorkOS webhook already deleted the organization)429rate limited503provisioning turned off (HEYCMO_TENANT_PROVISIONING=off)
POST /v1/tenants/organization-creation
AuthKit only (HEYCMO_IDENTITY_PROVIDER=workos): the signed-in person's turn to create a WorkOS organization from the dashboard, one at a time per person across dashboard tasks, held 60 s and never handed back early (dashboard session, with or without an organization; machine keys refused)
A signed-in dashboard session; API keys are not accepted.
Responses
201the turn is the caller's (turnSeconds)401missing or invalid WorkOS session403machine key (clerk_session_required)404Clerk is the identity provider429another creation holds the turn (code rate_limited, retry-after)503the turn cannot be taken now; nothing may be created
Testimonials
GET /v1/testimonials
#252: the customer quotes HeyCMO keeps (TESTIMONIAL knowledge: what review-and-ugc-harvest keeps from an asked customer's positive feedback, the words only), newest first, a page at a time, optionally in one state: waiting, confirmed (a draft may quote it; a CMS draft reads up to three), withdrawn, or changed (confirmed on words it no longer holds: waiting again). Each carries quoteSha256, the hash of its words now, which a confirmation names. The counts are over every quote. No drafter quotes a customer until a person confirms the customer agreed
Scope: read.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
state | query | no | one of "waiting", "confirmed", "withdrawn", "changed" | |
cursor | query | no | string (up to 512 characters) | the page before's nextCursor |
limit | query | no | integer 1–200 default 50 |
Responses
200{testimonials: [{id, source, words, quoteSha256, createdAt, permission: waiting|confirmed|withdrawn|changed, decision: {state, evidenceKind, evidence, decidedBy, decidedAt} | null}], counts: {waiting, confirmed, withdrawn, changed} (every quote), total, nextCursor (null on the last page), evidenceKinds: [{kind, label}], maxEvidence}400an unknown state, a cursor this list did not give, or a limit outside 1 to 200
PUT /v1/testimonials/{id}/permission
#252 (admin): confirm a customer quote's permission (how the customer agreed: their reply, a signed form or another record; where that is kept; and expectedQuoteSha256, the quoteSha256 of the words read) or withdraw it (optionally why). Records who decided, when and on what evidence, bound to the words decided on, and its audit row (testimonial_permission) in the same transaction. A confirmation of words the quote no longer holds answers 409 stale_words and confirms nothing. A confirmation lets the next draft quote it; a withdrawal takes every version of the words ever confirmed, and the words now, out of every search and out of every draft still waiting for approval (each draft changed with its own audit row, testimonial_quote_removed, in one transaction, and named in the answer); approved and published work keeps its words. Sending a withdrawal again is safe and finishes one that could not
Scope: admin.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
id | path | yes | string (uuid) | the testimonial (GET /v1/testimonials id) |
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
permission | yes | one of "confirmed", "withdrawn" | |
evidenceKind | no | one of "customer_reply", "signed_form", "other" | confirmed only (required): how the customer agreed |
evidence | no | string (up to 500 characters) or null | confirmed (required): where the agreement is kept; withdrawn (optional): why. Never an e-mail address |
expectedQuoteSha256 | no | string or null | confirmed (required): the quoteSha256 GET /v1/testimonials gave for the words read |
Responses
200{ok, testimonial (as GET lists it), before: the state before, auditId, quoteRemovedFrom: [{stagedActionId, loop, actionType}], quoteLeftIn: [{stagedActionId, actionType, reason}]}400invalid body: a confirmation without evidenceKind, evidence or expectedQuoteSha256, an e-mail address in the evidence, more than 500 characters, an evidenceKind on a withdrawal403read-only key, or not an admin404no such testimonial409{error, code: stale_words}: the words changed since they were read; nothing was confirmed503{error, code: sweep_incomplete, testimonial, auditId}: the withdrawal is recorded, but taking the quote out of the waiting drafts did not finish; withdraw again to finish
Tracking
GET /v1/tracking/settings
The GA4 key events tracking QA checks: the ones named here, or (keyEvents null) the GA4 property's own key events. An event not recorded in the last 90 days is not assessed rather than reported broken
Scope: read.
Responses
200{keyEvents: string[] | null, checks: named | ga4_key_events, recentDays, lookbackDays, reads}
PUT /v1/tracking/settings
Name the GA4 key events tracking QA checks (1 to 30 GA4 event names), or null to check the GA4 property's own key events (admin, audited without the names)
Scope: admin.
Request body (application/json)
| Field | Required | Type | Description |
|---|---|---|---|
keyEvents | yes | array of string (at least 1 characters, up to 40 characters) (at least 1, at most 30) or null | Each item: a GA4 event name: a letter, then letters, digits or underscores |
Responses
200{ok, keyEvents, checks, recentDays, lookbackDays, reads}400malformed body or event name403read-only key, or a key without the admin scope
Webhooks
POST /v1/webhooks/stripe/{tenantId}
Per-tenant Stripe webhook (signature-verified; anti-enumeration 503s): recovery attribution, dunning holds, conversions, and the dunning journey (issue #232: invoice.payment_failed stages the day-0 notice within the request, invoice.upcoming the pre-dunning email; W5-A1: customer.source.expiring stages the same pre-dunning email, once per customer, card and expiry month, for a legacy card an active subscription renews on; a paid or cancelled invoice withdraws its waiting drafts)
Called by the provider and signed with its secret; API keys are not accepted.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200processed (dunning: outcome, staged, reason)503not configured, or not finished (Stripe unreadable, run failed or busy): Stripe redelivers
POST /v1/webhooks/stripe-billing
HeyCMO platform billing webhook (Stripe-signed with STRIPE_BILLING_WEBHOOK_SECRET, replay-safe)
Called by the provider and signed with its secret; API keys are not accepted.
Responses
200applied, ignored, or duplicate400invalid signature or malformed event503webhook secret not configured; or stripe_unavailable — the event needs the subscription as Stripe reports it now (a payment that would restore access, a same-second subscription event) and it could not be read; not receipted, so Stripe delivers it again
POST /v1/webhooks/nango
Platform Nango auth webhook (X-Nango-Hmac-Sha256 over the raw body with NANGO_WEBHOOK_SECRET); state is re-read from Nango, so replays change nothing
Called by the provider and signed with its secret; API keys are not accepted.
Responses
200applied, unchanged, stale or ignored400invalid signature or malformed body429per-tenant delivery limit503webhook secret or Nango not configured, or Nango unreachable (retry)
POST /v1/webhooks/workos
WorkOS organization and membership events (W3-3; WorkOS-Signature t=<ms>, v1=<hex HMAC-SHA256> over "<t>.<raw body>" with WORKOS_WEBHOOK_SECRET). Owns WorkOS memberships of a tenant bound to the organization; never creates tenants
Called by the provider and signed with its secret; API keys are not accepted.
Responses
200handled, replayed, stale, or ignored (unknown organization, other event type)400invalid signature or stale timestamp409identity_webhook_conflict: the event id was seen with another body503WORKOS_WEBHOOK_SECRET not configured
POST /v1/webhooks/segment/{tenantId}
Per-tenant Segment destination: track, identify, page and screen calls (one message, an array, or {batch}) verified by the tenant's shared secret (X-Signature HMAC-SHA1 of the body, or Authorization: Bearer); each message receipted by its messageId. W5-D1: an anonymous page or track call (no userId) on a pricing, enterprise, security, MCP docs or integrations page that names a company domain (context.company.domain, or a companyDomain trait or property; never a personal mailbox domain) counts one company-intent hit: only the domain, the page class and the hour's count are kept
Called by the provider and signed with its secret; API keys are not accepted.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200{received, messages, accepted, duplicates, ignored, companyIntent, outcomes} (companyIntent: anonymous calls counted as company-level intent, included in accepted)400malformed body or too many messages413body over 512 KB429per-tenant delivery limit (Segment retries)503not configured
POST /v1/webhooks/github-release/{tenantId}
Per-tenant GitHub webhook (W5-D2): a published release (not a draft or pre-release) or a merged changesets Version Packages pull request starts a product_launch Marketing Factory run from the release notes, after the answer; verified by X-Hub-Signature-256 (HMAC-SHA256 of the raw body under the tenant's github_webhook_secret). Receipted by X-GitHub-Delivery and claimed by repository and tag, so a redelivery or a tag already launched starts nothing. JSON or form-encoded (payload=) bodies; ping is answered
Called by the provider and signed with its secret; API keys are not accepted.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string | |
X-GitHub-Event | header | yes | string | |
X-GitHub-Delivery | header | yes | string | |
X-Hub-Signature-256 | header | yes | string |
Responses
200{received, ping} | {received, launched: false, ignored | duplicate | blocked, reason?, release?}202{received, launched: true, attempt, release}: the launch runs after the answer400missing X-GitHub-Event / X-GitHub-Delivery, or a body that is not JSON401signature did not verify429per-tenant delivery limit (redeliver from GitHub)
POST /v1/webhooks/journalist-desk
Journalist desk messages from the inbound mail forwarder (#173): one JSON document per message (text and HTML parts, From, Subject, DKIM domains, envelope sender, SES verdicts), signed x-heycmo-desk-signature: v1=HMAC-SHA256(HEYCMO_JOURNALIST_DESK_FORWARDER_SECRET, timestamp.recipient.sha256(body)). Read only when the tenant's registered mailbox forwarded a known service's digest; nothing is sent
Called by the provider and signed with its secret; API keys are not accepted.
Responses
200nothing new (every request already stored, or past its deadline)201requests stored, or a Gmail forwarding confirmation code kept401missing, stale or invalid signature404unknown desk address413message too large422not a digest forwarded from a registered sender, or no request in it (code says which)423the desk is stopped (kill switch or the listening channel)503desk address or forwarder secret not configured
POST /v1/webhooks/resend/{tenantId}
Per-tenant Resend webhook (Svix-signed, replay-safe)
Called by the provider and signed with its secret; API keys are not accepted.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200received or duplicate503webhook unavailable or unauthenticated
POST /v1/webhooks/sendgrid/{tenantId}
Per-tenant SendGrid Event Webhook: a batch of events, ECDSA-signed (X-Twilio-Email-Event-Webhook-Signature over the timestamp and the raw body, the tenant's stored verification key); only events of HeyCMO's own mail (the heycmo category or a HeyCMO action id) are receipted, by sg_event_id; the rest are ignored
Called by the provider and signed with its secret; API keys are not accepted.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
tenantId | path | yes | string |
Responses
200{received, events, duplicates, ignored}400not a JSON array of events413body or batch too large503webhook unavailable or unauthenticated
Who am I
GET /v1/whoami
The caller's tenant (id, name, slug) and identity (kind, key id, scopes)
Scope: read.
Responses
200{tenant, identity}