# 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](https://docs.heycmo.ai/getting-started/). Help: [alpha@heycmo.ai](mailto: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`](https://agent-api.heycmo.ai/openapi.json) and this site at [`openapi.json`](https://docs.heycmo.ai/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 (or `heycmo 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](https://docs.heycmo.ai/api/#group-discovery-and-health) 14
- [Activity](https://docs.heycmo.ai/api/#group-activity) 2
- [Agents](https://docs.heycmo.ai/api/#group-agents) 1
- [API keys](https://docs.heycmo.ai/api/#group-api-keys) 4
- [Approve](https://docs.heycmo.ai/api/#group-approve) 1
- [Audit](https://docs.heycmo.ai/api/#group-audit) 5
- [Autonomy](https://docs.heycmo.ai/api/#group-autonomy) 4
- [Billing](https://docs.heycmo.ai/api/#group-billing) 4
- [Brand](https://docs.heycmo.ai/api/#group-brand) 1
- [Briefs](https://docs.heycmo.ai/api/#group-briefs) 3
- [Card update](https://docs.heycmo.ai/api/#group-card-update) 1
- [Connect](https://docs.heycmo.ai/api/#group-connect) 22
- [Connections](https://docs.heycmo.ai/api/#group-connections) 5
- [Content](https://docs.heycmo.ai/api/#group-content) 7
- [Context](https://docs.heycmo.ai/api/#group-context) 2
- [Conversions](https://docs.heycmo.ai/api/#group-conversions) 4
- [Creative](https://docs.heycmo.ai/api/#group-creative) 16
- [Decline](https://docs.heycmo.ai/api/#group-decline) 1
- [Deliverability](https://docs.heycmo.ai/api/#group-deliverability) 2
- [Email tool (ESP)](https://docs.heycmo.ai/api/#group-email-tool-esp) 3
- [Experiments](https://docs.heycmo.ai/api/#group-experiments) 5
- [External writes](https://docs.heycmo.ai/api/#group-external-writes) 10
- [Factory](https://docs.heycmo.ai/api/#group-factory) 10
- [Founder post](https://docs.heycmo.ai/api/#group-founder-post) 2
- [GEO (AI answers)](https://docs.heycmo.ai/api/#group-geo-ai-answers) 10
- [Governor](https://docs.heycmo.ai/api/#group-governor) 1
- [Inbox](https://docs.heycmo.ai/api/#group-inbox) 2
- [Ingest](https://docs.heycmo.ai/api/#group-ingest) 1
- [Journalist desk](https://docs.heycmo.ai/api/#group-journalist-desk) 7
- [Kill switch](https://docs.heycmo.ai/api/#group-kill-switch) 3
- [Landing pages](https://docs.heycmo.ai/api/#group-landing-pages) 8
- [Loop specs](https://docs.heycmo.ai/api/#group-loop-specs) 2
- [Loops](https://docs.heycmo.ai/api/#group-loops) 8
- [Marketing](https://docs.heycmo.ai/api/#group-marketing) 14
- [Marketing state](https://docs.heycmo.ai/api/#group-marketing-state) 4
- [MCP](https://docs.heycmo.ai/api/#group-mcp) 1
- [Mesh](https://docs.heycmo.ai/api/#group-mesh) 6
- [Metrics](https://docs.heycmo.ai/api/#group-metrics) 11
- [Onboarding](https://docs.heycmo.ai/api/#group-onboarding) 2
- [Operation runs](https://docs.heycmo.ai/api/#group-operation-runs) 10
- [Operations](https://docs.heycmo.ai/api/#group-operations) 2
- [Packs](https://docs.heycmo.ai/api/#group-packs) 2
- [Platform](https://docs.heycmo.ai/api/#group-platform) 22
- [Playbooks](https://docs.heycmo.ai/api/#group-playbooks) 2
- [Product analytics](https://docs.heycmo.ai/api/#group-product-analytics) 1
- [Push](https://docs.heycmo.ai/api/#group-push) 4
- [Reviews](https://docs.heycmo.ai/api/#group-reviews) 3
- [SEO](https://docs.heycmo.ai/api/#group-seo) 5
- [Session](https://docs.heycmo.ai/api/#group-session) 1
- [Slack](https://docs.heycmo.ai/api/#group-slack) 2
- [Staged](https://docs.heycmo.ai/api/#group-staged) 1
- [Status](https://docs.heycmo.ai/api/#group-status) 1
- [Strategy](https://docs.heycmo.ai/api/#group-strategy) 3
- [Subjects](https://docs.heycmo.ai/api/#group-subjects) 1
- [Suppressions](https://docs.heycmo.ai/api/#group-suppressions) 3
- [Tenant](https://docs.heycmo.ai/api/#group-tenant) 26
- [Tenants](https://docs.heycmo.ai/api/#group-tenants) 3
- [Testimonials](https://docs.heycmo.ai/api/#group-testimonials) 2
- [Tracking](https://docs.heycmo.ai/api/#group-tracking) 2
- [Webhooks](https://docs.heycmo.ai/api/#group-webhooks) 9
- [Who am I](https://docs.heycmo.ai/api/#group-who-am-i) 1

## 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

- `200` OpenAPI 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

- `200` the 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}
- `404` mcp_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}
- `404` mcp_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}
- `404` cli_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}
- `404` cli_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}
- `404` cli_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

- `200` the verification token (text/plain, no-store)
- `404` no 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

- `200` text/html
- `404` nothing published on this hostname, taken down, or not through the edge
- `429` rate 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

- `204` nothing to read
- `404` not a test reference
- `413` body 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

- `302` to the approved goal URL (no-store)
- `404` no such link, or its test is no longer served
- `429` rate 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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 } }
- `403` admin 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` {keys}
- `403` admin scope required

### POST `/v1/api-keys`

Create an API key (admin). The secret is in this response only

Scope: [`admin`](https://docs.heycmo.ai/api/#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}
- `400` bad body
- `403` admin scope required, or a scope the caller does not hold (scope_escalation)
- `429` credential 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`](https://docs.heycmo.ai/api/#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}
- `404` no such key in this tenant
- `409` key 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` {revoked, alreadyRevoked, key}
- `404` no such key in this tenant
- `409` last_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`](https://docs.heycmo.ai/api/#scope-approve).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `operationHash` | no | string |  |

#### Responses

- `200` executed or blocked
- `400` invalid approval body
- `403` scope, or human_approval_required with approveUrl (a machine key where people sign in)
- `409` stale 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`](https://docs.heycmo.ai/api/#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, and `by`, 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. `clientId` is the assistant's or the terminal's OAuth client; `person` (the address) and `key` are 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.
- `400` tier 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` ok, 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` application/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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` state requested
- `400` terms not acknowledged or bad body
- `402` plan does not include autonomy (Operator, trial, restricted)
- `409` not 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` {state: gated, changed}

## Billing

### GET `/v1/billing/prices`

HeyCMO platform tiers + Checkout availability

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` tiers + 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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` plan + 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` url + sessionId; the same request again gets the same open session (one per tenant), another plan or return URL expires it in Stripe first
- `400` return URL not on the public site or dashboard origin
- `409` subscription_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 apply
- `503` platform Stripe not configured

### POST `/v1/billing/portal`

Create Stripe Customer Portal session {returnUrl?} (admin)

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` url
- `400` return URL not on the public site or dashboard origin
- `409` no_billing_customer — start with checkout
- `503` platform 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`](https://docs.heycmo.ai/api/#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 kit
- `400` neither 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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 } }
- `404` brief not found

### POST `/v1/briefs/{id}/ack`

Acknowledge a completed weekly brief idempotently

Scope: [`approve`](https://docs.heycmo.ai/api/#scope-approve).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Responses

- `200` acknowledgement recorded
- `409` brief 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

- `200` text/html: dry run (nothing created in Stripe), or no fallback (reply to the email)
- `303` to the Stripe Billing Portal session, or to the fallback (no-store, no referrer)
- `400` text/html: not a valid card-update link (forged, cut, or signed before SECRETS_MASTER_KEY was rotated); nothing in it is followed
- `410` text/html: the link expired and has no fallback
- `429` rate 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 instead
- `503` text/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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `422` key invalid or cannot read invoices — nothing stored
- `502` Stripe 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`](https://docs.heycmo.ai/api/#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}
- `422` key invalid — nothing stored
- `502` Resend 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`](https://docs.heycmo.ai/api/#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}
- `409` email_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 kept
- `422` key invalid or without mail.send — nothing stored
- `502` SendGrid 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `422` key invalid — nothing stored
- `502` Customer.io unreachable — nothing stored

### POST `/v1/connect/loops`

Validate a Loops API key (GET /api/v1/api-key) and store it

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `apiKey` | yes | string |  |

#### Responses

- `200` {ok, team, keyStored}
- `422` key invalid — nothing stored
- `502` Loops 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `privateKey` | yes | string |  |

#### Responses

- `200` {ok, account, keyStored, writes: none}
- `422` key invalid or missing a scope — nothing stored
- `502` Klaviyo 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `token` | yes | string |  |
| `productId` | yes | string (uuid) |  |

#### Responses

- `200` {ok, product, productId, keyStored}
- `422` token invalid, no access to the product, or product not found — nothing stored
- `502` G2 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`](https://docs.heycmo.ai/api/#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}
- `422` key invalid, its role cannot read reviews, or app not found — nothing stored
- `502` App 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`](https://docs.heycmo.ai/api/#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}
- `422` not a service-account key, or it lacks the "Reply to reviews" permission — nothing stored
- `502` Google 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`](https://docs.heycmo.ai/api/#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}
- `400` not a Sanity connection
- `422` token invalid, lacking a permission, or the project/dataset not found — nothing stored
- `502` Sanity 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`](https://docs.heycmo.ai/api/#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}
- `400` not a Contentful connection
- `422` token invalid, a content type, field or locale missing or of the wrong type — nothing stored
- `502` Contentful 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`](https://docs.heycmo.ai/api/#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}
- `400` not a Git connection (a directory outside the repository, a hidden directory or a bad branch is refused)
- `422` token invalid or without access, branch or directory missing — nothing stored
- `502` GitHub 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `422` not a personal API key, unknown project, or missing project:read / query:read — nothing stored
- `502` PostHog 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`](https://docs.heycmo.ai/api/#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}
- `422` not a service account, unknown project, or no access to it — nothing stored
- `502` Mixpanel 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`](https://docs.heycmo.ai/api/#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}
- `422` the key pair is refused — nothing stored
- `502` Amplitude 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` {ok, provider, webhookUrl, sharedSecret (shown once), rotated, setup}
- `429` too 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` {ok, provider, webhookUrl, secret (shown once), rotated, contentType, events, setup}
- `403` read-only key
- `429` too 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `404` unknown_provider
- `409` crm_switch_required — another CRM is connected; {currentProvider, requestedProvider}, nothing changed
- `429` credential change rate limit
- `502` nango_unavailable — nothing changed
- `503` oauth_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`](https://docs.heycmo.ai/api/#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}
- `400` invalid_resource
- `409` not_connected
- `422` resource_not_applicable (hubspot, attio, pipedrive, twenty); resource_from_connection (x)

### DELETE `/v1/connections/{provider}`

Disconnect: delete the Nango connection (admin). Idempotent

Scope: [`admin`](https://docs.heycmo.ai/api/#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}
- `502` nango_unavailable — nothing changed
- `503` oauth_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`](https://docs.heycmo.ai/api/#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)}
- `409` not_connected
- `502` provider_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`](https://docs.heycmo.ai/api/#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

- `200` a 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}
- `400` invalid body, or the write refused the article at staging
- `404` not_found — source action not found (another workspace's too)
- `409` source_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)
- `422` not_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`](https://docs.heycmo.ai/api/#scope-execute).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `draftActionId` | yes | string (uuid) |  |

#### Responses

- `200` the publish of this draft was already staged
- `201` {stagedActionId, alreadyStaged: false, actionType, channels, target, preview, draftActionId, editorialItemId, posture}
- `404` not_found — draft not found
- `409` not_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`](https://docs.heycmo.ai/api/#scope-execute).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `sourceActionId` | yes | string (uuid) |  |

#### Responses

- `200` every card was already staged
- `201` {staged: \[{pageUrl, stagedActionId, alreadyStaged, edits}\], unmatched: \[{from, to, reason}\], channels, posture}
- `400` invalid body, or the write refused an edit at staging
- `404` not_found — source action not found
- `409` cms_not_connected, cms_paused (the cms kill switch stopped the read of a page), unsupported_platform (WordPress only) or page_not_found
- `422` not_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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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 causal
- `400` weeks 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`](https://docs.heycmo.ai/api/#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}
- `400` invalid body
- `404` not found (another workspace's item too)
- `409` the 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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 template
- `400` neither siteUrl nor url, or two different sites: {error} says which field to send
- `409` a 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `unknownConsent` | no | one of "skip", "send" |  |
| `destinations` | no | object | `google_ads` (object) { `trial_started` (string or null); `first_paid_invoice` (string or null); `subscription_active` (string or null) }; `linkedin` (object) { `trial_started` (string or null); `first_paid_invoice` (string or null); `subscription_active` (string or null) } |

#### Responses

- `200` {ok, settings, destinations}
- `400` malformed 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`](https://docs.heycmo.ai/api/#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}
- `400` exactly one of customerId or email, and at least one consent field
- `503` consent 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `visitorId` | yes | string |  |
| `touches` | yes | array of object (at least 1, at most 20) | `at` (string (date-time), required); `gclid` (string); `gbraid` (string); `wbraid` (string); `msclkid` (string); `li_fat_id` (string); `fbclid` (string); `utm_source` (string (at least 1 characters, up to 200 characters)); `utm_medium` (string (at least 1 characters, up to 200 characters)); `utm_campaign` (string (at least 1 characters, up to 200 characters)); `utm_term` (string (at least 1 characters, up to 200 characters)); `utm_content` (string (at least 1 characters, up to 200 characters)); `referrer` (string (up to 253 characters)) the referring host name only; `hc_ref` (string) |

#### Responses

- `200` {ok, stored, duplicates, touches: \[{source, medium, campaign, channelGroup}\]} — the channel each touch resolves to; no id is returned
- `400` an 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 site
- `403` read-only key
- `413` body larger than 16 KB
- `503` click 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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` settings, 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` updated
- `400` invalid 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `briefActionId` | query | no | string (uuid) |  |
| `limit` | query | no | integer 1–200 |  |

#### Responses

- `200` assets

### 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` asset, verification
- `404` not 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`](https://docs.heycmo.ai/api/#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 | `headline` (string, required); `body` (string); `cta` (string); `eyebrow` (string) |

#### Responses

- `200` {image: data:image/webp;base64,…, templateId, sizeId, width, height} (width and height are the full size)
- `400` invalid_input: an unknown template or size, or copy over its limits
- `403` the studio is off
- `429` rate_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`](https://docs.heycmo.ai/api/#scope-execute).

#### Responses

- `201` assets
- `400` invalid template, size or copy
- `403` read-only key or studio off
- `423` creative kill switch engaged
- `503` asset 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Responses

- `201` asset
- `400` model not allowed or invalid input
- `402` monthly generation budget exceeded
- `422` prompt blocked or provider refused
- `423` creative kill switch engaged
- `503` provider, 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `actionId` | path | yes | string (uuid) |  |

#### Responses

- `201` assets
- `207` some assets made before a refusal
- `400` no creative brief, no matching items, or invalid body
- `404` not 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`](https://docs.heycmo.ai/api/#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 | `repo` (string, required) owner/name; `tagName` (string, required); `name` (string); `body` (string, required) the release notes (markdown); `url` (string (uri), required) the release's own page, https://\<GitHub host\>/\<repo\>/releases/tag/\<tagName\> (else 400); `publishedAt` (string) |
| `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}
- `400` invalid body: exactly one of factoryRunId and release; a release url that is not its repo's page for its tag
- `403` read-only key or studio off
- `404` no such run or studio asset
- `409` not a release launch run, no release facts, or a studio asset that does not verify
- `422` release notes a launch cannot cite (a draft, a pre-release, empty)
- `423` creative kill switch engaged
- `503` asset 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `assetId` | path | yes | string (uuid) |  |

#### Responses

- `200` {aspect, specSha256, spec, remotion, captionsSrt, storyboard, provenance, rendered: false}
- `404` not found, or not a launch video storyboard
- `409` the storyboard or its spec no longer verifies (tampered, manifest or spec mismatch, untrusted signer)
- `503` asset 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `403` studio off, or the Canva plan lacks brand templates
- `409` Canva not offered or not connected
- `423` creative kill switch engaged
- `429` Canva's rate limit
- `502` Canva 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `templateId` | path | yes | string |  |

#### Responses

- `200` {template, fields, stale}
- `404` not found
- `409` Canva not offered or not connected
- `423` creative 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`](https://docs.heycmo.ai/api/#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}
- `400` malformed mapping, unknown field, or a source that cannot fill the field
- `403` read-only key or not admin
- `409` a 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Request body (application/json, required)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `brandTemplateId` | yes | string |  |
| `copy` | no | object | `headline` (string); `body` (string); `cta` (string); `eyebrow` (string) |
| `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} started
- `400` invalid body, or nothing to fill
- `403` read-only key, studio off, or the Canva plan lacks Autofill
- `409` Canva not connected, or a studio image that is not signed
- `423` creative kill switch engaged
- `429` three 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` {render}
- `404` not found

## Decline

### POST `/v1/decline/{id}`

Decline staged action (reject_action)

Scope: [`approve`](https://docs.heycmo.ai/api/#scope-approve).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Responses

- `200` declined

## 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Request body (application/gzip)

string (binary)

#### Responses

- `200` duplicate (already stored)
- `201` stored {reportId, orgName, domain, records, messages, passRate}
- `413` too large
- `415` not gzip, zip or XML
- `422` malformed, 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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: \[...\]}
- `404` no ESP connected

### GET `/v1/esp/customerio/audience`

Customer.io API-triggered broadcasts and segment ids/names (never members)

Scope: [`read`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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, …}
- `400` invalid body or the operation was refused (attestation, person-level fields)
- `404` broadcast or segment not found, or no Customer.io connected
- `422` the 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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 | `minUnitsPerArm` (integer or null ≥ 10); `maxDays` (integer 14–365); `probabilityThreshold` (number 0.8–0.999) |

#### Responses

- `201` {experiment, consent}
- `400` invalid input or loop not wired
- `409` key already used

### POST `/v1/experiments/{id}/start`

Start a drafted holdout (admin). Needs the acknowledged consent: held-out units get no action

Scope: [`admin`](https://docs.heycmo.ai/api/#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

- `200` running
- `400` consent not acknowledged (the body carries the terms)
- `404` not this tenant’s experiment
- `409` not 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`](https://docs.heycmo.ai/api/#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?}
- `404` not this tenant’s experiment
- `409` not 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Responses

- `200` {experiment, scope: tenant, analysis, pendingUnits}
- `404` not this tenant’s experiment

## External writes

### GET `/v1/external-writes`

External-write adapters and the tenant's recent append-only attempt ledger

Scope: [`read`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` the identical operation was already staged today
- `201` {stagedActionId, actionType, channels, target, preview, estimateSpendCents, posture}
- `400` the adapter refused the operation or its preview
- `404` no external-write adapter for actionType
- `409` a 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`](https://docs.heycmo.ai/api/#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

- `200` this source was already sent to that CMS
- `201` {stagedActionId, actionType, channels, target, preview, posture, source}
- `404` source action not found
- `409` no (or no single) CMS connected
- `422` not_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`](https://docs.heycmo.ai/api/#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

- `200` every variant was already staged
- `201` {sourceActionId, platform, target, variants: \[{label, slug, stagedActionId, alreadyStaged}\], channels, posture}
- `404` source action not found
- `409` no (or no single) connection that keeps landing pages
- `422` the 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`](https://docs.heycmo.ai/api/#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

- `200` this page was already sent to LinkedIn from this source
- `201` {stagedActionId, actionType, channels, target, preview, posture, sourceActionId, url}
- `404` source action or creative asset not found
- `409` LinkedIn 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 staged
- `422` the 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`](https://docs.heycmo.ai/api/#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

- `200` this page was already sent to X from this source
- `201` {stagedActionId, actionType, channels, target, preview, estimateSpendCents, posture, sourceActionId, url}
- `404` source action not found
- `409` X 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 staged
- `422` the 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` the 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}
- `400` invalid body, or the adapter refuses the change (code invalid_operation, e.g. beyond ±30%)
- `403` read-only key
- `404` campaign_not_found or ad_group_not_found in the connected account
- `409` not_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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` the same reversal was already staged (a concurrent request)
- `201` {stagedActionId, actionType, channels, target, preview, estimateSpendCents, reverses, compensation, posture}
- `404` no write with this id in this workspace
- `409` not applied, a reversal is pending, or the write was already reversed (code, reversalActionId)
- `422` not 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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. `metricId` (string, required) An existing collected metric id, including currency or event when applicable.; `targetValue` (number ≥ 0, required) A proposed target in the metric’s units, not a forecast.; `reviewOn` (string (date), required) |
| `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

- `201` FactoryRunRecord: blueprint, drafts, checks, approval state and generation provenance
- `400` Missing goal or invalid measurement
- `403` execute scope required

### GET `/v1/factory/runs/{runId}`

Read this workspace’s campaign and current edited copy and delivery state (read scope)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `runId` | path | yes | string |  |

#### Responses

- `200` FactoryRunRecord, hydrated from current staged actions
- `404` Campaign 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`](https://docs.heycmo.ai/api/#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. `metricId` (string, required) An existing collected metric id, including currency or event when applicable.; `targetValue` (number ≥ 0, required) A proposed target in the metric’s units, not a forecast.; `reviewOn` (string (date), required) |
| `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

- `200` Updated FactoryRunRecord
- `400` Invalid input or missing campaign context
- `403` approve scope or human_approval_required with approveUrl
- `404` Campaign not found
- `409` Campaign 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `runId` | path | yes | string |  |

#### Responses

- `200` FactoryResults {delivery, measurement}
- `404` Campaign not found

### POST `/v1/factory/runs/{runId}/results`

Record a sourced result review for future planning (execute scope)

Scope: [`execute`](https://docs.heycmo.ai/api/#scope-execute).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `runId` | path | yes | string |  |

#### Responses

- `200` FactoryResults; evidence saved with causal:false and an audit record
- `400` A due goal and a trusted observation collected since the campaign began are required
- `403` execute scope required
- `404` Campaign 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`](https://docs.heycmo.ai/api/#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\]}\]}
- `400` unknown 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`](https://docs.heycmo.ai/api/#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)}
- `400` body must name a channel
- `403` read-only key
- `409` the 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`](https://docs.heycmo.ai/api/#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)
- `400` invalid channel or toVersion
- `403` read-only key
- `404` unknown_version
- `409` no_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`](https://docs.heycmo.ai/api/#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}}}\]}
- `400` days 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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)
- `403` read-only key or no admin scope
- `422` invalid_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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `excludedFromGoogleAi` | yes | boolean |  |

#### Responses

- `200` {settings}
- `400` invalid 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` {run, summary, metricIds, results}
- `404` not this tenant's run

### GET `/v1/geo/prompt-sets`

The GEO prompt set's versions (every save is a new immutable version)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` {versions}

### GET `/v1/geo/prompt-sets/{version}`

One GEO prompt-set version

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `version` | path | yes | integer ≥ 1 |  |

#### Responses

- `200` {promptSet}
- `404` no 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `prompts` | yes | array of object (at least 1, at most 200) | `text` (string (at least 8 characters, up to 300 characters), required) |
| `brand` | yes | object | `name` (string, required); `domains` (array of string); `aliases` (array of string) |
| `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}
- `400` invalid input
- `402` daily 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` {draft, notes} (save: false)
- `201` {promptSet, notes}
- `422` nothing 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `promptId` | path | yes | string |  |

#### Responses

- `200` {briefActionId, alreadyStaged: true, query}: the question already has a brief
- `201` {briefActionId, alreadyStaged: false, query}
- `403` read-only key
- `404` not_a_gap: no such question, or no assistant links a competitor and not you for it in the newest panel
- `409` no_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`](https://docs.heycmo.ai/api/#scope-execute).

#### Request body (text/csv)

string (up to 262144 characters)

#### Responses

- `201` {export: {report, uploadedAt, rows, from, to, totalImpressions}}
- `413` over 256 KB or 1,000 rows
- `415` not one of the two accepted Search Console tables
- `422` a 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` inbox items + capStatus
- `400` Invalid status or actionId
- `401` auth

### 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`](https://docs.heycmo.ai/api/#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; `page` gives its Search Console clicks, impressions and position; `newPages` counts 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `kind` | path | yes | string |  |

#### Responses

- `200` snapshot 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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` address, 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` updated
- `400` invalid 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`](https://docs.heycmo.ai/api/#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}
- `400` invalid body
- `403` read-only key
- `404` no 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`](https://docs.heycmo.ai/api/#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}
- `400` invalid body
- `403` read-only key
- `404` no 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` {ok, sentTo: the address it went to, emailedAt}
- `403` a key, an MCP client or the CLI (code person_required), or a read-only session
- `404` no such request
- `409` no fact sheet yet (code no_fact_sheet), or the sign-in has no email address (code no_email)
- `429` ten this hour already (code rate_limited)
- `502` the email could not be sent (code email_failed)
- `503` platform 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`](https://docs.heycmo.ai/api/#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}
- `400` invalid body
- `403` read-only key
- `404` no 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`](https://docs.heycmo.ai/api/#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 not
- `404` not 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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` {killSwitch, channels, platform:{global,channels,tools}, engaged, ceilings}
- `503` switch 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`](https://docs.heycmo.ai/api/#scope-execute) or [`admin`](https://docs.heycmo.ai/api/#scope-admin) to turn it on, [`admin`](https://docs.heycmo.ai/api/#scope-admin) to turn it off, and wherever people sign in only a signed-in person turns it off.

#### Responses

- `200` updated
- `403` scope, 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`](https://docs.heycmo.ai/api/#scope-execute) or [`admin`](https://docs.heycmo.ai/api/#scope-admin) to turn it on, [`admin`](https://docs.heycmo.ai/api/#scope-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

- `200` updated
- `400` bad channel/body
- `403` scope, 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}}
- `400` invalid_hostname (an apex domain, a URL, a reserved or HeyCMO name)
- `402` plan_entitlement: a no-card trial, no plan, or a restricted workspace
- `409` hostname_connected, hostname_taken (another workspace holds it verified), hostname_limit, or variant_host_unconfigured
- `423` a 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` {hostname}; lastError awaiting_ownership while the TXT record is missing
- `404` not this tenant's hostname
- `409` hostname_taken, variant_host_unconfigured or not_ready
- `429` check_cooldown (retryAfterSeconds), or the host is rate limiting
- `502` the host refused (closed code)
- `503` dns_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`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` {removed, id, testsTakenDown, dnsCleanup: {message, records}}
- `404` not this tenant's hostname
- `409` hostname_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`](https://docs.heycmo.ai/api/#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

- `200` the same card was already staged today
- `201` {stagedActionId, alreadyStaged, actionType, channels, target, preview, sourceActionId, hostname, variants, goalUrl, goalKind, posture}
- `400` invalid input, or the operation refused (invalid_operation)
- `404` source or hostname not found
- `409` hostname_not_verified
- `422` not_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`](https://docs.heycmo.ai/api/#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}
- `404` not 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `201` {stagedActionId, alreadyStaged, preview, posture}
- `404` not this tenant's test
- `409` not_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`](https://docs.heycmo.ai/api/#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}
- `404` not this tenant's test
- `409` not_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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` AI-authored specs with status

### POST `/v1/loop-specs/{loopId}/retire`

Retire a dynamic loop spec (admin)

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `loopId` | path | yes | string |  |

#### Responses

- `200` retired
- `404` no such spec

## Loops

### GET `/v1/loops`

Loop catalog (list_loops)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` loops 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`](https://docs.heycmo.ai/api/#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}\]}
- `400` invalid body, a setting the loop does not have (code unknown_setting), or a value out of range (code out_of_range)
- `403` admin scope required, or a read-only key
- `409` the 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` harness 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 adds `collection` (as POST /v1/tenant/listening/collect answers); a run whose self-check found no data adds `remedies` (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`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Responses

- `200` harness result (backlog: true)
- `409` regular runs already staging this loop
- `422` loop 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Responses

- `200` {rehearsal: true, stagedActionId, alreadyStaged, sent: false}; alreadyStaged: one was already waiting
- `403` read-only key
- `404` {error, code: rehearsal_not_found}: the workspace does not exist
- `409` {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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` register report

## Marketing

### GET `/v1/marketing/operations`

Read all 52 marketing skills, pinned methodology availability and actual native provider readiness

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `workId` | path | yes | string (uuid) |  |
| `taskId` | path | yes | string |  |

#### Responses

- `200` Exact UTF-8 artifact, original provenance and immutable acceptance; no Markdown field extraction.
- `403` Read scope required.
- `404` Missing, 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `runId` | path | yes | string |  |
| `stepId` | path | yes | string |  |

#### Responses

- `200` Full-source identity remains separate from deterministic escaped HTML; draft only, not delivery or model-quality proof.
- `403` Read scope required.
- `404` Missing, foreign, incomplete, rehearsed, changed or erased source; no existence disclosure.

### GET `/v1/marketing/work`

List marketing work and result review dates

Scope: [`read`](https://docs.heycmo.ai/api/#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

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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) | `id` (string, required); `skill` (string, required); `title` (string (up to 200 characters), required); `reason` (string (up to 2000 characters), required); `dependsOn` (array of string (at most 12)); `driver` (, required) |

#### Responses

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid bounded input, date, task graph, metric or operation.

### POST `/v1/marketing/work/{id}/revise`

Replace a plan before acceptance; optimistic version check

Scope: [`execute`](https://docs.heycmo.ai/api/#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 | `goal` (string (at least 1 characters, up to 2000 characters), required); `reviewOn` (string (date), required); `metricId` (string (up to 200 characters)); `tasks` (array of object (at least 1, at most 30), required) { `id` (string, required); `skill` (string, required); `title` (string (up to 200 characters), required); `reason` (string (up to 2000 characters), required); `dependsOn` (array of string (at most 12)); `driver` (, required) } |

#### Responses

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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

- `200` Tenant-scoped work record or capability inventory; execution evidence is read from governed child records.
- `403` Insufficient scope or person-only decision.
- `409` Stale version, changed context, prerequisites pending or provider evidence unavailable.
- `422` Invalid 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` the record (as GET) plus {changed, fields}
- `400` not an object, or no field named
- `403` read-only key or no admin scope
- `409` version_conflict: expectedVersion is not the current version
- `422` invalid_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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `expectedVersion` | no | integer ≥ 0 |  |

#### Responses

- `200` the record (as GET) plus {monitors: \[{id, name, monitorIds, created, pages, note}\], changed}
- `403` read-only key or no admin scope
- `409` not_configured (no context.dev key), or version_conflict
- `422` no_competitors: no record competitor has a domain
- `502` monitors_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`](https://docs.heycmo.ai/api/#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}}
- `404` no 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`](https://docs.heycmo.ai/api/#scope-read) to list and read, the scope of the route a tool calls to act.

#### Responses

- `200` JSON-RPC result or JSON-RPC error
- `202` notification accepted
- `400` parse error (-32700), batch or invalid JSON-RPC (-32600), envelope/header mismatch
- `401` missing or invalid bearer token (WWW-Authenticate: Bearer realm="heycmo"; with MCP OAuth configured also resource_metadata=\<RFC 9728 URL\>)
- `403` insufficient_scope (OAuth tokens get a WWW-Authenticate step-up challenge naming the heycmo:\* scope)
- `405` GET/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`](https://docs.heycmo.ai/api/#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.
- `400` Invalid input
- `403` Execution scope required or workspace restricted

### POST `/v1/mesh/route`

Classify request → specialist delegations (spec 09, planning only)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` delegation plan

### POST `/v1/mesh/council`

Run marketing-council recipe (spec 09 AC-3)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` council output

### POST `/v1/mesh/seo-audit`

Plan seo-audit fan-out (spec 09 AC-2/AC-5)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` fan-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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `date` | query | no | string (date) |  |

#### Responses

- `200` tenantId, runDate, runOrder, conflicts, budget, auditId, planned, plannedAt, movedIn
- `400` date is not a calendar day

### GET `/v1/mesh/specialists`

Specialist roster browse (spec 09)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` roster

## 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}}
- `400` invalid 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`](https://docs.heycmo.ai/api/#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 report
- `400` invalid range; currency_required (a money family) or event_required (the per-event family acquisition.key_event.by_channel), each with the tenant's `ids`
- `404` unknown 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`](https://docs.heycmo.ai/api/#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 absent
- `400` invalid range, or currency_required: a money family is one metric per currency (`<id>.<currency>`), never a sum; `ids` lists the tenant's
- `404` unknown 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`](https://docs.heycmo.ai/api/#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 reads
- `400` invalid range, or currency_required for a bare money family
- `404` unknown metric id

### GET `/v1/metrics/recovery`

Recovered revenue attribution

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` recoveredCents30d … 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`](https://docs.heycmo.ai/api/#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}}
- `400` days 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`](https://docs.heycmo.ai/api/#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}\]}}\]}
- `400` unknown conversion, or days outside 1..365

### GET `/v1/metrics/alpha`

Compact alpha decision and recovery scorecard

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` source-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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` secrets/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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` ok, 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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | no | integer 1–100 default 20 |  |

#### Responses

- `200` {runs, count}: only this workspace’s runs
- `400` Invalid limit

### POST `/v1/operation-runs`

Persist a validated, idempotent operation plan; does not execute it (execute scope)

Scope: [`execute`](https://docs.heycmo.ai/api/#scope-execute).

#### Request body (application/json, required)

See the summary.

#### Responses

- `201` {run}: the persisted operation run, with bounded, secret-filtered response content
- `400` Invalid plan or input, unknown/unimplemented operation, forbidden fields or secret material
- `403` Insufficient scope, read-only identity or restricted workspace
- `409` The idempotency key belongs to a different plan

### GET `/v1/operation-runs/{runId}`

Read one persisted run from this workspace (read scope)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `runId` | path | yes | string |  |

#### Responses

- `200` {run}: the persisted operation run, with bounded, secret-filtered response content
- `404` Operation 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`](https://docs.heycmo.ai/api/#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 content
- `400` Only maxSteps is accepted; no caller evidence, approval proof, output, or tenant identity
- `403` Insufficient scope, read-only identity or restricted workspace
- `404` Operation run not found, including another workspace’s run
- `409` Another 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`](https://docs.heycmo.ai/api/#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.
- `400` Invalid identifiers or closed request body
- `403` Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person
- `409` Stale, 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`](https://docs.heycmo.ai/api/#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.
- `400` Invalid identifiers or closed request body
- `403` Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person
- `409` Stale, 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`](https://docs.heycmo.ai/api/#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.
- `400` Invalid identifiers or closed request body
- `403` Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person
- `409` Stale, 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`](https://docs.heycmo.ai/api/#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.
- `400` Invalid identifiers or closed request body
- `403` Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person
- `409` Stale, 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`](https://docs.heycmo.ai/api/#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.
- `400` Invalid identifiers or closed request body
- `403` Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person
- `409` Stale, 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`](https://docs.heycmo.ai/api/#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.
- `400` Invalid identifiers or closed request body
- `403` Insufficient scope, restricted workspace, or decision attempted without a verified Clerk/WorkOS person
- `409` Stale, 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-read).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `operationId` | path | yes | string |  |

#### Responses

- `200` {operation}: the definition and binding availability
- `404` Operation 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Responses

- `200` {status: enabled|already_enabled, enabled, revision, warnings}
- `404` unknown 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`](https://docs.heycmo.ai/api/#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}
- `404` unknown pack
- `409` already enabled, or turned off by the operator

## Platform

### GET `/v1/platform/killswitches`

Platform kill switches and recent history (operator tenant admin only)

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Responses

- `200` {switches, history}
- `403` not the operator tenant admin

### POST `/v1/platform/killswitches`

Engage/release a global, channel or tool platform kill switch (operator tenant admin only)

Scope: [`admin`](https://docs.heycmo.ai/api/#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

- `200` updated row
- `400` bad kind/target
- `403` not the operator tenant admin

### GET `/v1/platform/live-send`

Live-send requests and recent transitions across tenants (operator tenant admin only)

Scope: [`admin`](https://docs.heycmo.ai/api/#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)
- `403` not 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`](https://docs.heycmo.ai/api/#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

- `200` state approved
- `403` not the operator tenant admin
- `409` not 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`](https://docs.heycmo.ai/api/#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 revoked
- `403` not the operator tenant admin

### GET `/v1/platform/autonomy`

Autonomy threshold sets, requests, grants and recent transitions across tenants (operator tenant admin only)

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Responses

- `200` {enabled, current, thresholdSets, grants, history}
- `403` not the operator tenant admin

### POST `/v1/platform/autonomy/thresholds`

Propose a new, unsigned threshold set version (operator tenant admin only)

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Responses

- `201` proposed set with its hash
- `400` rules invalid or below the floors
- `403` not 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`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Responses

- `200` signed-current
- `403` not the operator tenant admin
- `409` hash 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`](https://docs.heycmo.ai/api/#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 autonomous
- `403` not the operator tenant admin
- `409` not 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`](https://docs.heycmo.ai/api/#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}
- `403` not the operator tenant admin

### GET `/v1/platform/write-channels`

Write-channel requests and recent transitions across tenants (operator tenant admin only)

Scope: [`admin`](https://docs.heycmo.ai/api/#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}
- `403` not 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`](https://docs.heycmo.ai/api/#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

- `200` state approved
- `403` not the operator tenant admin
- `409` not 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`](https://docs.heycmo.ai/api/#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

- `200` state revoked
- `403` not 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`](https://docs.heycmo.ai/api/#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}\]}
- `403` not 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`](https://docs.heycmo.ai/api/#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}
- `400` invalid or inconsistent evidence
- `403` not the operator tenant admin
- `404` malformed tenant or action id
- `409` the 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`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Responses

- `200` {writes}
- `403` not 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`](https://docs.heycmo.ai/api/#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}
- `403` not the operator tenant admin
- `409` the 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`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Responses

- `200` {scope: platform, analysis, allocationsDiffer, stratified, byExperiment}
- `403` not the operator tenant admin

### GET `/v1/platform/marketing-policy`

Platform marketing send policy per type and its history (operator tenant admin only)

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Responses

- `200` {ceiling, types, history}
- `403` not 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`](https://docs.heycmo.ai/api/#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}
- `403` not 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`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | yes | string |  |

#### Responses

- `200` as GET /v1/tenant/caps
- `403` operator_only / operator_tenant_unconfigured
- `404` tenant 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`](https://docs.heycmo.ai/api/#scope-admin). HeyCMO operator only (an admin key of the operator workspace).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `tenantId` | path | yes | string |  |

#### Responses

- `200` as GET /v1/tenant/caps
- `400` invalid_value, unknown_field, invalid_reason, nothing_to_change
- `403` operator_only / operator_tenant_unconfigured
- `404` tenant not found

## Playbooks

### GET `/v1/playbooks`

List playbooks (list_playbooks)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` registry

### POST `/v1/playbooks/run`

Start playbook (run_playbook)

Scope: [`execute`](https://docs.heycmo.ai/api/#scope-execute).

#### Responses

- `200` episode started or suspended
- `503` a 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `provider` | no | one of "posthog", "mixpanel", "amplitude" |  |
| `events` | no | object | `active` (string or null); `activation` (string or null); `trial` (string or null) |

#### Responses

- `200` {ok, provider, events, collecting}
- `400` malformed body or event name
- `409` the 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-approve).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `endpoint` | yes | string (uri) (up to 2048 characters) |  |
| `keys` | yes | object | `p256dh` (string, required) base64url uncompressed P-256 public key; `auth` (string, required) base64url 16-octet authentication secret |

#### Responses

- `200` this device was already registered (keys refreshed, or moved to you)
- `201` registered; the view plus subscriptionId
- `400` not a valid push subscription
- `403` an API key, a read-only session or an inactive membership
- `409` the per-person device limit is reached
- `503` push is not configured on this deployment

### DELETE `/v1/push/subscriptions/{id}`

Remove one of your devices (a signed-in person; audited)

Scope: [`approve`](https://docs.heycmo.ai/api/#scope-approve).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string (uuid) |  |

#### Responses

- `200` {removed}
- `404` not 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`](https://docs.heycmo.ai/api/#scope-approve).

#### Responses

- `200` {ok, outcome, sent, failed, expired}: delivered to at least one device
- `409` suppressed by a kill switch
- `422` no device took it
- `429` rate limited
- `503` push 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-execute).

#### Responses

- `200` {sources: \[{provider, read, added, updated, flagged}\], failures}
- `404` no review source connected
- `423` the 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`](https://docs.heycmo.ai/api/#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, …}
- `400` the reply was refused (incentive, a request to change the review, too long, markup)
- `404` no such review
- `422` G2 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `monthlyBudgetCents` | yes | integer 0–10000000 |  |

#### Responses

- `200` the budget view
- `400` invalid 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`](https://docs.heycmo.ai/api/#scope-execute).

#### Responses

- `200` this 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}}
- `403` read-only key
- `409` no_site — no verified site to crawl; in_progress — an audit of the site is already running (nothing crawled)
- `502` crawl_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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` the Slack settings as GET /v1/slack returns them
- `400` channelId is not a channel id, or teamId is not a workspace id
- `409` slack_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`](https://docs.heycmo.ai/api/#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}
- `404` not this tenant’s action
- `422` not an email send

## Status

### GET `/v1/ops/heartbeat`

Product health (unauthenticated; 503 when degraded)

No key: anyone can call it.

#### Responses

- `200` ok
- `503` degraded

## 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`](https://docs.heycmo.ai/api/#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}
- `404` no 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`](https://docs.heycmo.ai/api/#scope-approve).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `seq` | path | yes | integer ≥ 1 |  |

#### Responses

- `200` plan progress after the decision: {status, version, sections, nextPending}
- `400` the section is missing or not drafted
- `403` read-only key, scope, or human_approval_required (with approveUrl)
- `404` no 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`](https://docs.heycmo.ai/api/#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}\]}
- `400` the plan is not ready (a section not approved, wrong status)
- `409` marketingskills is not enabled at a ready revision for this tenant
- `422` invalid 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`](https://docs.heycmo.ai/api/#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}
- `400` invalid_email: the body must be {"email": "\<one e-mail address\>"}
- `403` read-only key or no admin scope
- `429` too 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-admin).

#### Request body (application/json, required)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `address` | yes | string |  |

#### Responses

- `200` { added: false, reason, source, addedAt }: already listed
- `201` { added: true }
- `400` not one email address
- `403` admin 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`](https://docs.heycmo.ai/api/#scope-read).

#### Request body (application/json, required)

| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `address` | yes | string |  |

#### Responses

- `200` { listed: false } | { listed: true, reason, source, addedAt }
- `400` not 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`](https://docs.heycmo.ai/api/#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 deleted
- `409` affiliate_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 kept
- `422` unknown 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` state requested
- `400` terms not acknowledged
- `409` preconditions_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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` state revoked
- `409` already 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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

- `200` state requested
- `400` terms not acknowledged
- `404` no adapter acts in this channel
- `409` readiness 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `channel` | path | yes | string |  |

#### Responses

- `200` state revoked
- `409` already 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `400` invalid platform, ad account or text
- `409` another 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `id` | path | yes | string |  |

#### Responses

- `200` {authorization}
- `404` no such authorisation

### GET `/v1/tenant/notifications`

Approval-notification settings (admin): recipients, coalescing window, Slack configured, last outcome

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` settings (the Slack URL is never returned)

### PUT `/v1/tenant/notifications`

Set approval-notification recipients, window, or the Slack incoming webhook (admin)

Scope: [`admin`](https://docs.heycmo.ai/api/#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

- `200` updated settings
- `400` invalid input

### POST `/v1/tenant/notifications/test`

Send a test approval notification to the configured channels now (admin)

Scope: [`admin`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` delivered to at least one channel
- `409` suppressed by a kill switch
- `422` no 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `400` invalid input
- `404` unknown 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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` dunningConfig, 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` updated
- `400` invalid value or touch plan
- `422` unknown 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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` the updated caps (as GET)
- `400` above_ceiling (with field and ceiling), no_ceiling (a currency the operator capped nowhere), invalid_value, unknown_field, nothing_to_change
- `403` read-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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` {exportVersion: 2, exportedAt, note, truncated: \[tables that hit the cap\], tenant, tables: {\<table\>: rows}} (content-disposition: attachment)
- `403` read-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`](https://docs.heycmo.ai/api/#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}}
- `400` confirmation required: the body must be {"confirm": "\<tenantId\>"}
- `403` read-only key or no admin scope
- `409` this tenant owns the deployment-level Stripe webhook (HEYCMO_OPERATOR_TENANT_ID)
- `500` tenant_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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` contactFrequency, 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` updated
- `400` not a JSON object
- `403` read-only key
- `422` value 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`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` settings, 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 with `label`, 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`](https://docs.heycmo.ai/api/#scope-admin).

#### Responses

- `200` updated: {ok, settings, terms, marketingStateCompetitors, xMonthlyBudgetCents}
- `400` invalid 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`](https://docs.heycmo.ai/api/#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}
- `403` read-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

- `200` state (unprovisioned|provisioned|deleted), member, canProvision, tenant (members only)
- `401` missing or invalid Clerk session
- `403` machine key (clerk_session_required) or no active organization (clerk_org_required)
- `429` rate 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

- `200` already provisioned by this caller (created: false)
- `201` tenant created (created: true)
- `400` invalid name or timezone
- `401` missing or invalid Clerk session
- `403` clerk_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)
- `409` org_already_bound | org_tenant_deleted | org_deleted (AuthKit: a WorkOS webhook already deleted the organization)
- `429` rate limited
- `503` provisioning 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

- `201` the turn is the caller's (turnSeconds)
- `401` missing or invalid WorkOS session
- `403` machine key (clerk_session_required)
- `404` Clerk is the identity provider
- `429` another creation holds the turn (code rate_limited, retry-after)
- `503` the 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`](https://docs.heycmo.ai/api/#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}
- `400` an 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`](https://docs.heycmo.ai/api/#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}\]}
- `400` invalid body: a confirmation without evidenceKind, evidence or expectedQuoteSha256, an e-mail address in the evidence, more than 500 characters, an evidenceKind on a withdrawal
- `403` read-only key, or not an admin
- `404` no such testimonial
- `409` {error, code: stale_words}: the words changed since they were read; nothing was confirmed
- `503` {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`](https://docs.heycmo.ai/api/#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`](https://docs.heycmo.ai/api/#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}
- `400` malformed body or event name
- `403` read-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

- `200` processed (dunning: outcome, staged, reason)
- `503` not 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

- `200` applied, ignored, or duplicate
- `400` invalid signature or malformed event
- `503` webhook 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

- `200` applied, unchanged, stale or ignored
- `400` invalid signature or malformed body
- `429` per-tenant delivery limit
- `503` webhook 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

- `200` handled, replayed, stale, or ignored (unknown organization, other event type)
- `400` invalid signature or stale timestamp
- `409` identity_webhook_conflict: the event id was seen with another body
- `503` WORKOS_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)
- `400` malformed body or too many messages
- `413` body over 512 KB
- `429` per-tenant delivery limit (Segment retries)
- `503` not 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 answer
- `400` missing X-GitHub-Event / X-GitHub-Delivery, or a body that is not JSON
- `401` signature did not verify
- `429` per-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

- `200` nothing new (every request already stored, or past its deadline)
- `201` requests stored, or a Gmail forwarding confirmation code kept
- `401` missing, stale or invalid signature
- `404` unknown desk address
- `413` message too large
- `422` not a digest forwarded from a registered sender, or no request in it (code says which)
- `423` the desk is stopped (kill switch or the listening channel)
- `503` desk 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

- `200` received or duplicate
- `503` webhook 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}
- `400` not a JSON array of events
- `413` body or batch too large
- `503` webhook unavailable or unauthenticated

## Who am I

### GET `/v1/whoami`

The caller's tenant (id, name, slug) and identity (kind, key id, scopes)

Scope: [`read`](https://docs.heycmo.ai/api/#scope-read).

#### Responses

- `200` {tenant, identity}
