API reference

Every endpoint the API publishes, how to authenticate and the scope each call needs.

View as Markdown

HeyCMO is an invitation-only, $0 operational Alpha for Stripe and Resend recovery emails. A person approves every message before it is sent. The guides also describe implementation capabilities outside the Alpha. Their presence here does not make them available in the pilot. Start with the Alpha checklist. Help: alpha@heycmo.ai.

The HeyCMO API, version 0.9.0, at https://agent-api.heycmo.ai. Every call is HTTPS with a JSON body. This page shows the API's contract: the same document the API serves at https://agent-api.heycmo.ai/openapi.json and this site at openapi.json, for your own tools.

Authentication and scopes

API keys and dashboard sessions

Send a bearer token in the Authorization header: Authorization: Bearer <token>. The token is one of:

  • An API key (hcmo_…) for the CLI, scripts and AI assistants. Create one in the dashboard, Settings → Team & API keys (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.

ScopeWhat it allows
readSee the workspace: the inbox, loops and their runs, metrics, the audit trail, connections and settings. Changes nothing.
approveDecide: 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.
executeStart 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.
adminChange 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.

ScopeWhat it allows
heycmo:readThe read scope: the MCP tools that list and read.
heycmo:approveThe approve scope: decline an inbox item (approving one still needs a person in the dashboard).
heycmo:executeThe execute scope: plan a campaign or start a playbook.

Endpoints

315 endpoints in 61 groups. Each says who may call it.

Discovery and health

GET /health

Liveness

No key: anyone can call it.

Responses

  • 200 {ok, service, commit, builtAt, wave}: commit is the full SHA this build was made from and builtAt its build time (null for a local run); wave is a fixed legacy value

GET /openapi.json

This document

No key: anyone can call it.

Responses

  • 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

NameInRequiredTypeDescription
testpathyesstring (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

NameInRequiredTypeDescription
testpathyesstring (uuid)
labelpathyesstring
indexpathyesinteger 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.

Parameters

NameInRequiredTypeDescription
limitquerynointeger 1–200 default 100

Responses

  • 200 {sends: [{id, loop, actionType, to, subject, at, dryRun}], limit, more}

GET /v1/activity/week

What HeyCMO did in the last 7 days, for the home's "This week" (B6): loop runs, items staged, decisions, emails sent (the sends /v1/activity/sends lists in the window: live, rehearsed, not recorded), external writes applied and reversals, payments recovered per currency

Scope: read.

Responses

  • 200 {since, until, days, loops: {runs, loopsRun, runsWithFindings, failedRuns}, staged, decided: {approved, declined}, sends: {total, live, rehearsed, unrecorded}, writes: {applied, reversals}, recovered: [{currency, cents, count, emailDrivenCents}]}

Agents

GET /v1/agents

Agents and keys (admin): HeyCMO's MCP address, whether people sign in with an account (then no key approves), whether an AI assistant (MCP OAuth) and the terminal (heycmo login) can sign in here, the assistants and terminal sign-ins that reached this workspace (each OAuth client and member, first and last seen, most recent first), and what went out in the last seven days by who decided it. API keys stay at GET /v1/api-keys

Scope: admin.

Responses

  • 200 { mcpUrl, signIn: { people, assistants, terminal }, connections: [{ id, kind: mcp|cli, clientId, person, firstSeenAt, lastSeenAt }], week: { from, to, shipped, byPeople, standing, other, edited } }
  • 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.

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.

Request body (application/json)

FieldRequiredTypeDescription
labelyesstring (at least 1 characters, up to 100 characters)
scopesyesarray of one of "read", "approve", "execute", "admin" (at least 1)
expiresInDaysnointeger 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
expiresInDaysnointeger 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
operationHashnostring

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.

Parameters

NameInRequiredTypeDescription
limitquerynointeger 1–200 default 50
loopquerynostring
actionquerynostring
tierquerynointeger
decisionquerynostringexecuted, staged, blocked, escalated, approved, declined…; several comma-separated match any of them.
surfacequerynostringdashboard, pwa, slack, mcp, cli, api, system…
fromquerynostring (date-time)Only entries written at or after this ISO time.
toquerynostring (date-time)Only entries written before this ISO time.
cursorquerynostring (uuid)An entry id from the page shown: the entries older than it, the next page back.
todayFromquerynostring (date-time)Where "today" starts for summary.today (the reader's local midnight); UTC midnight by default.
byquerynostringOnly 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.
notKeyquerynostring (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.

Responses

  • 200 {loops, actions}

GET /v1/audit/verify

Recompute this tenant's audit hash chain and check it against its daily anchors (E2.4)

Scope: read.

Responses

  • 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.

Responses

  • 200 { checkedAt, state: verified|failed|unchained, entries, failure: { kind, seq } | null }, or { checkedAt: null } before the first check

GET /v1/audit/export

Audit evidence export: NDJSON header, pre-chain rows, chained rows with hashes (E2.4)

Scope: read.

Responses

  • 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.

Responses

  • 200 {enabled, readOnly, plan, thresholdSet, bar, terms, policyDiagnostics, pairs, history, floor}; bar is the thresholds every pair is measured against {default, channels, actionTypes, incidents} (the signed set's rules, or the proposed defaults before one is signed); the payment-recovery send pair (failed-payment-dunning, send) also carries touchPlan {reminderDay, urgentDay, finalDay, includeRetrying, preDunning, summary}: the touch plan its grant covers (issue #232); design v2: each pair's lastEdit {at, fields, subject: {before, after} | null, added, removed} | null, the latest edit of a decision its record counts (a card still waiting is left out) from the feedback ledger (scrubbed: the words the edit added and took out, at most 120 characters each)

GET /v1/autonomy/eligibility

Autonomy eligibility from real decisions (approval rate, edit rate, observation days, time to decide) and incidents, against the operator-signed thresholds

Scope: read.

Responses

  • 200 {evaluatedAt, thresholdSet, pairs: [{loop, actionType, stats, incidents, rule, eligible, reasons}]}

POST /v1/autonomy/request

Request autonomy for one eligible loop/action type (admin; OS or Alpha plan). The platform operator approves

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
loopyesstring
actionTypeyesstring
acknowledgeTermsyesmust be true
termsVersionyesstring
reasonnostring (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.

Responses

  • 200 {state: gated, changed}

Billing

GET /v1/billing/prices

HeyCMO platform tiers + Checkout availability

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.

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.

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.

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.

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)

FieldRequiredTypeDescription
siteUrlnostring (uri)
urlnostring (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.

Responses

  • 200 { briefs: [{ id, status, generatedAt, sentAt, week: { from, to }, acknowledgedAt, shipped }] }

GET /v1/briefs/{id}

One weekly brief with its week (read): its payload (the digest and the movers), what went out that week grouped by action type and job (how many a person edited first, how many a standing approval sent), and the week's runs (how many, how many completed with nothing to do, the last failed ones)

Scope: read.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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

NameInRequiredTypeDescription
pqueryyesstringthe signed payload (base64url)
squeryyesstringits 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.

Responses

  • 200 {stripe, resend}

POST /v1/connect/stripe

Validate a restricted Stripe key, store it, and register the attribution webhook (signing secret stored); manual steps when the key cannot manage webhooks

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
restrictedKeyyesstring
replaceWebhooknoboolean

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.

Request body (application/json)

FieldRequiredTypeDescription
apiKeyyesstring

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.

Request body (application/json)

FieldRequiredTypeDescription
apiKeyyesstring
regionnoone of "global", "eu"
switchProvidernoboolean

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.

Responses

  • 200 {providers: [{provider, connected, credentialSource, writes, region?}], writeChannel, attestationVersion}

POST /v1/connect/customerio

Validate a Customer.io App API key (GET /v1/workspaces; both regions probed when none is given) and store it

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
appApiKeyyesstring
regionnoone 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.

Request body (application/json)

FieldRequiredTypeDescription
apiKeyyesstring

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.

Request body (application/json)

FieldRequiredTypeDescription
privateKeyyesstring

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.

Responses

  • 200 {providers: [{provider, label, connected, target, replies}], writeChannel, deferred}

POST /v1/connect/g2

Validate a G2 API token against the product (GET /api/v1/products/{id}) and store it; G2 is read only

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
tokenyesstring
productIdyesstring (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.

Request body (application/json)

FieldRequiredTypeDescription
issuerIdyesstring (uuid)
keyIdyesstring
privateKeyyesstringthe .p8 file contents (PKCS#8 PEM, EC P-256)
appIdyesstring

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.

Request body (application/json)

FieldRequiredTypeDescription
serviceAccountyesthe service-account key file (JSON, as text or object)
packageNameyesstring

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.

Request body (application/json)

FieldRequiredTypeDescription
projectIdyesstring
datasetyesstring
tokenyesstringa project API token with the Editor role
postTypenostringdocument type of posts (default post)
pageTypenostringdocument type of landing pages; without it no landing pages are written
bodyFormatnoone of "portableText", "markdown", "html"
fieldsnoobjectfield 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.

Request body (application/json)

FieldRequiredTypeDescription
spaceIdyesstring
environmentIdnostringdefault master
tokenyesstringa Content Management API token of a user who may edit and publish
regionnoone of "us", "eu"
localenostringdefault en-US
postTypenostringcontent type of posts (default blogPost)
pageTypenostringcontent type of landing pages; without it no landing pages are written
bodyFormatnoone of "richText", "markdown"
fieldsnoobjectfield 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.

Request body (application/json)

FieldRequiredTypeDescription
repoyesstringowner/name
tokenyesstringa fine-grained token for this repository: Contents and Pull requests read and write
baseBranchnostringdefault: the repository default branch
postsDiryesstring
pagesDirnostringlanding pages; without it none are written
formatnoone of "md", "mdx"
frontMatternoobjectkeys: 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.

Responses

  • 200 {providers: [{provider, label, connected, credentialSource, target, region, oauthAvailable?}], collecting, provider, events: {active, activation, trial}, reads}

POST /v1/connect/posthog

Validate a PostHog personal API key against the project (GET /api/projects/{id}/ and a SELECT 1 HogQL query) and store it; read only

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
personalApiKeyyesstring
projectIdyesstring
regionnoone 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.

Request body (application/json)

FieldRequiredTypeDescription
serviceAccountUsernameyesstring
serviceAccountSecretyesstring
projectIdyesstring
regionnoone 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.

Request body (application/json)

FieldRequiredTypeDescription
apiKeyyesstring
secretKeyyesstring
regionnoone 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.

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.

Responses

  • 200 {configured, webhookUrl, events, lastDeliveryAt, lastDeliveryEvent, launchesPerDay, launchesToday, launches: [{origin, repo, tagName, url, status, runId, stage, stagedCount, attempts, reason, startedAt, finishedAt}], blocked: [{origin, repo, tagName, url, block, reason, receivedAt}]}; status is started, awaiting_approval, completed, declined, bailed, failed, blocked or interrupted, and follows the run once a person decides it; blocked lists the releases a gate (kill switch, restriction, no plan, daily cap) kept from starting that have not launched since

POST /v1/connect/github-release

Create (or rotate) the GitHub release webhook's secret; returns it once with the per-tenant webhook URL and the setup steps; the old secret stops working at once (admin, audited without the secret)

Scope: admin.

Responses

  • 200 {ok, provider, webhookUrl, secret (shown once), rotated, contentType, events, setup}
  • 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.

Responses

  • 200 {configured, crmProvider, providers}

POST /v1/connections/{provider}/session

Create a Nango Connect session for this tenant and one provider (admin); a reconnect session when a connection exists. The connection is usable once the Nango webhook reports it. One CRM per tenant (hubspot, attio, pipedrive, twenty — Twenty by an API key the tenant pastes into the Nango Connect page): connecting a CRM while another is connected needs {switchProvider: true}; the previous CRM connection is deleted once Nango confirms the new one

Scope: admin.

Parameters

NameInRequiredTypeDescription
providerpathyesone of "ga4", "gsc", "googleads", "hubspot", "bing", "webflow", "postmaster", "meta", "linkedin", "x", "attio", "pipedrive", "canva", "microsoftads", "twenty"

Request body (application/json)

FieldRequiredTypeDescription
switchProvidernobooleanCRMs 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.

Parameters

NameInRequiredTypeDescription
providerpathyesone of "ga4", "gsc", "googleads", "bing", "meta", "linkedin", "x", "microsoftads"

Request body (application/json)

FieldRequiredTypeDescription
resourceIdnostringrequired except for linkedin
organizationIdnostring or nulllinkedin only
adAccountIdnostring or nulllinkedin only
personIdnostring or nulllinkedin 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.

Parameters

NameInRequiredTypeDescription
providerpathyesone 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.

Parameters

NameInRequiredTypeDescription
providerpathyesone 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.

Request body (application/json)

FieldRequiredTypeDescription
sourceActionIdyesstring (uuid)
pageUrlnostring (up to 2048 characters)a content refresh brief: the page to refresh (one it lists)
querynostring (up to 300 characters)an SEO content brief with several searches: the one to draft (default the first); each search has its own card
platformnoone 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.

Request body (application/json)

FieldRequiredTypeDescription
draftActionIdyesstring (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.

Request body (application/json)

FieldRequiredTypeDescription
sourceActionIdyesstring (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.

Responses

  • 200 {source, updatedAt, pages, derived: {at, windowDays, gsc, ga4, cms, notes} | null, nextRefreshAfter, topPages: [{url, title, traffic90d, trafficPrev90d, position, pageViews90d, pageViewsPrev90d, lastRefreshedAt, sources}], losing: [the pages content-decay counts as losing visitors (at least 25% down on the 90 days before), the worst first, at most 25: {…topPages fields, drop, refresh: {briefActionId, briefStatus, briefAt, draftActionId, draftStatus, draftRehearsed: the edit ran as a rehearsal and changed nothing} | null}], losingTotal, links: design v2, the newest internal-linking report (internal_link_draft) of the last 45 days unless declined: {actionId, loop, status, at, linkTo: [{url, title}] (pages that get few links), linkFrom: [{url, title}] (the busiest pages; the first 3 are the ones POST /v1/content/link-edits edits), broken (internal links it found broken), drafts: [the cms.updatePost link edits staged from it, the newest per page: {actionId, url, title, links, anchors: [{text, href}] (the words each link goes on, a phrase the page already has, and the page it points to), status, rehearsed}]} | null}

POST /v1/content/snapshot/refresh

Derive the content snapshot now from Search Console, GA4 and the CMS, whatever its age (execute); never over a partner push. A page seen for the first time since the previous derivation is proposed for IndexNow (one card, its own approval)

Scope: execute.

Responses

  • 200 {refresh: {status: derived, pages, updatedAt, derived, newUrls, indexNow} | {status: skipped, reason: partner_push | no_source | conflict, detail}, snapshot}

GET /v1/content/calendar

The editorial calendar (#233): one item per topic and week (Monday, UTC), from two weeks back (or from the week of from), with its channel, status (idea, briefed, drafted, published, dropped), the loop that first proposed it and every proposer since, and the brief, draft and publish cards. Items of earlier weeks that are not done come first: a brief or draft waiting on a person (briefed, drafted), and a published post or edit at a known address whose 56-day page readback is not in yet while the daily readback pass may still record it

Scope: read.

Parameters

NameInRequiredTypeDescription
weeksquerynointeger 1–26 default 8
fromquerynostring (date)A calendar day (YYYY-MM-DD): the calendar starts at its week and runs weeks weeks; default two weeks back, running weeks + 2
sincequerynostring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
statusyesone 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.

Responses

  • 200 { version, versions: [{ version, fromVersion, section, reason, proposedBy, acceptedBy, at }], latest: [same fields] }: version is the current one, null before the first context (when versions and latest are empty too); both lists newest first

POST /v1/context/draft

Draft the product context from the site (execute). A blank template (no model, or HEYCMO_DRAFTER=template) comes back with a warning; drafting again replaces an untouched template

Scope: execute.

Request body (application/json, required)

The site to read: siteUrl, as POST /v1/onboarding/bootstrap names it, or url (accepted too; both given must name the same address, compared as URLs: case of scheme and host, default port, fragment and a trailing slash do not count)

FieldRequiredTypeDescription
siteUrlnostring (uri)
urlnostring (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.

Parameters

NameInRequiredTypeDescription
limitquerynointeger 1–200

Responses

  • 200 {dryRun, validateOnly, settings: {unknownConsent, destinations}, destinations: [{destination, label, ready, reason, account, mapped}], stripe: {keyStored, reason, events, note}, maxAttempts, stripeEvents, metadataKeys, counts30d, recent: [{id, destination, conversionType, status, dryRun, sourceObjectId, destinationRef, occurredAt, valueMinor, currency, identifierKind, adUserData, adPersonalization, consentSource, providerRequestId, reason, createdAt}], capture: {endpoint, liveModeOnly, liveModeNote, metadataKeys: {visitor, clickIds, utm, hosted}, cookies, touchRetentionDays, maxTouchesPerRequest, snippet, serverHelper}} (#235: first-party click capture)

PUT /v1/conversions/settings

Map a Google Ads conversion action and a LinkedIn conversion rule (ids as digit strings; null unmaps; fields not sent are kept) to each conversion type, and choose what an unknown ad_user_data consent does (admin, audited)

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
unknownConsentnoone of "skip", "send"
destinationsnoobject
  • 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.

Request body (application/json)

FieldRequiredTypeDescription
customerIdnostring
emailnostring (up to 320 characters)
adUserDatanoone of "granted", "denied", "unknown"
adPersonalizationnoone of "granted", "denied", "unknown"
tcfStringnostring (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.

Request body (application/json)

FieldRequiredTypeDescription
visitorIdyesstring
touchesyesarray 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.

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.

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.

Parameters

NameInRequiredTypeDescription
briefActionIdquerynostring (uuid)
limitquerynointeger 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
sizeIdnostring
copyyesobject
  • 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.

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.

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.

Parameters

NameInRequiredTypeDescription
actionIdpathyesstring (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.

Request body (application/json, required)

FieldRequiredTypeDescription
factoryRunIdnostringa product_launch run a GitHub release started
releasenoobject
  • 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)
aspectsnoarray of one of "16:9", "9:16", "1:1"default 16:9 and 9:16
assetIdsnoarray of string (uuid) (at most 3)signed studio images shown on feature scenes
storyboardnobooleandefault true
dryRunnoboolean

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.

Parameters

NameInRequiredTypeDescription
assetIdpathyesstring (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.

Responses

  • 200 {connection, renders}

GET /v1/creative/canva/templates

The connected Canva user's brand templates that have autofill fields, newest first, and whether the Canva plan has Autofill (governed tool creative.listCanvaTemplates)

Scope: read.

Parameters

NameInRequiredTypeDescription
queryquerynostring (up to 100 characters)
continuationquerynostring (up to 500 characters)
limitquerynointeger 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.

Parameters

NameInRequiredTypeDescription
templateIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
templateIdpathyesstring

Request body (application/json, required)

FieldRequiredTypeDescription
fieldsyesobject

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.

Request body (application/json, required)

FieldRequiredTypeDescription
brandTemplateIdyesstring
copynoobject
  • headline (string)
  • body (string)
  • cta (string)
  • eyebrow (string)
briefActionIdnostring (uuid)
briefItemIdnostring
imagesnoobjectimage field name → studio asset id
formatnoone of "png", "jpg"
titlenostring (up to 200 characters)
dryRunnoboolean

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Responses

  • 200 {render}
  • 404 not found

Decline

POST /v1/decline/{id}

Decline staged action (reject_action)

Scope: approve.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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.

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.

Responses

  • 200 {domain, statuses, sources, dmarc, postmaster}

Email tool (ESP)

GET /v1/esp/metrics

Live per-message and per-day email metrics from the connected ESP(s), through governed reads (aggregates only, no people)

Scope: read.

Parameters

NameInRequiredTypeDescription
providerquerynoone of "customerio", "loops", "klaviyo"
daysquerynointeger 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.

Responses

  • 200 {region, broadcasts, segments}

POST /v1/esp/customerio/broadcast-triggers

Stage a Tier-2 customerio.triggerBroadcast to an existing segment, with the broadcast email snapshotted for approval and the approver's consent attestation; nothing is sent here

Scope: execute.

Request body (application/json)

FieldRequiredTypeDescription
broadcastIdyesinteger
segmentIdyesinteger
attestationyesobject
reasoningnostring

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.

Responses

  • 200 {experiments: [{…, sourceActionId, stopNotice}], supportedLoops, consent}

POST /v1/experiments

Draft a holdout experiment (admin): failed-payment-dunning (unit invoice), re-engagement or trial-conversion (unit Stripe customer, E3.10); defaults to a 10% control

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
loopIdnoone of "failed-payment-dunning", "re-engagement", "trial-conversion"
keynostring
hypothesisnostring (up to 1000 characters)
controlAllocationnonumber 0.05–0.5
mdenonumber 0.01–0.5absolute, in rate units (0.10 = 10 pp)
outcomeWindowDaysnointeger 7–90
stoppingRulenoobject
  • 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
acknowledgeHoldoutyesmust be true
consentVersionyesstring

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
concludenoboolean

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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.

Responses

  • 200 {adapters, attempts}

POST /v1/external-writes

Stage an external write (E4.0) for human approval: validated by its adapter, stored as the canonical operation the approval binds. Sends nothing

Scope: execute.

Request body (application/json)

FieldRequiredTypeDescription
actionTypeyesstring
payloadyesobjectthe 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)
idempotencyKeynostring (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.

Request body (application/json)

FieldRequiredTypeDescription
sourceActionIdyesstring (uuid)
platformnoone 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.

Request body (application/json)

FieldRequiredTypeDescription
sourceActionIdyesstring (uuid)
platformnoone 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.

Request body (application/json)

FieldRequiredTypeDescription
sourceActionIdyesstring (uuid)
urlnostringwhich content-repurposing candidate page (default: the first)
assetIdnostring (uuid)E4.4: one of the tenant's C2PA-signed creative assets, posted as the image (the link stays in the text)
authornoone 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.

Request body (application/json)

FieldRequiredTypeDescription
sourceActionIdyesstring (uuid)
urlnostringwhich 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.

Responses

  • 200 {networks: [{network, name, target, currencyCode, campaigns: [{campaignId, name, status, dailyBudgetMicros, biddingStrategyType, actions: [pause|enable|budget|bid], budgetNote?, adGroups?: [{adGroupId, name, bid: cpc|target_cpa, bidMicros}]}], truncated}], unavailable: [{network, name, reason}]}

POST /v1/external-writes/ad-changes

Propose an ad change (C3): stage a pause, a turn-on, a daily budget change or (Google Ads) an ad group bid change as the same Tier-2 card the ad-fatigue producers would — the named campaign read live on its own (listed by GET /v1/external-writes/ad-campaigns or not), the same payload, the same staging as POST /v1/external-writes. Sends nothing; a person approves it in the inbox. A budget or bid change is bounded to ±30% per approval

Scope: execute.

Request body (application/json)

FieldRequiredTypeDescription
actionTypeyesone of "googleads.updateCampaignStatus", "googleads.updateCampaignBudget", "googleads.updateAdGroupBid", "microsoftads.updateCampaignStatus", "microsoftads.updateCampaignBudget"
campaignIdyesstring
statusnoone of "paused", "enabled"status changes
deltaPctnonumber -30–30budget and bid changes: a percent change, not 0
amountnostringbudget and bid changes, instead of deltaPct: the new amount in the account currency, e.g. "120.50"
adGroupIdnostringbid changes
reasoningnostring (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.

Parameters

NameInRequiredTypeDescription
limitquerynointeger 1–50

Responses

  • 200 {writes: [{id, actionType, provider, channels, target, title, executedAt, externalRef, reverses, reversal, outcome: {metricId, why, page?: {url, isNew}, readbacks: [{metricId, windowDays, changeDay, readiness, outcome, label, beforeMean, afterMean, difference, relativeChange, trust, causal: false, note, page: {url, isNew, clicks: {before, after}, impressions: {before, after}, position: {before, after}} | null}]}}], more} — more: older applied writes exist past this page; #235 outcome: the metric the write is meant to move and its recorded 14- and 28-day readbacks (directional, never causal); #233: a CMS write that changed a live page targets that page (page), read back on it in Search Console at 14, 28 and 56 days, and a draft save targets nothing

POST /v1/external-writes/{actionId}/reverse

Request the reversal of one applied external write: stages a Tier-2 <actionType>.reversal item bound to exactly that write (its staged action, approved operation hash, operation digest and provider ref). Sends nothing; a person approves it in the inbox

Scope: execute.

Parameters

NameInRequiredTypeDescription
actionIdpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
reasoningnostring (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.

Parameters

NameInRequiredTypeDescription
limitquerynointeger 1–100 default 20
approvedquerynoboolean default falseOnly campaigns with a currently approved, executing or executed card; filtered before limiting.
openquerynoboolean default falseOnly campaigns still under way (running, or waiting on a person); filtered before limiting.

Responses

  • 200 {items: FactoryRunRecord[], count}

POST /v1/factory/runs

Plan and draft a campaign from a marketing goal (execute scope)

Reads tenant context. Missing audience, message or destination holds the plan for correction. checkpointBlueprint requests plan review before drafting; MCP always requests it. Drafts stage in the approval inbox and never publish directly.

Scope: execute.

Request body (application/json, required)

FieldRequiredTypeDescription
goalyesstring (at least 1 characters)
targetIcpnostringThe intended audience; missing context holds drafting for correction.
pillarnostringCustomer-approved message or product fact, never an achieved campaign target.
ctaUrlnostring (uri)The product destination; HTTP(S), without embedded credentials.
channelsnoarray of one of "cms", "linkedin", "x", "email", "ads", "landing"
measurementnoobject or nullOmit 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)
triggerKindnoone of "custom_goal", "competitor_move", "product_launch", "churn_recovery", "seo_gap"
targetKeywordsnoarray of string
checkpointBlueprintnoboolean default false
useLlmnoboolean default trueUse 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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring

Request body (application/json, required)

FieldRequiredTypeDescription
targetIcpnostringThe intended audience; missing context holds drafting for correction.
pillarnostringCustomer-approved message or product fact, never an achieved campaign target.
ctaUrlnostring (uri)The product destination; HTTP(S), without embedded credentials.
channelsnoarray of one of "cms", "linkedin", "x", "email", "ads", "landing"
measurementnoobject or nullOmit 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)
approvednoboolean
editedGoalnostring
decisionnoone of "approve_all", "decline_all", "complete"
operationHashesnoobjectapprove_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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
channelquerynoone 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.

Request body (application/json)

FieldRequiredTypeDescription
channelyesone 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.

Request body (application/json)

FieldRequiredTypeDescription
channelyesone of "linkedin", "x", "cms", "email", "ads", "landing"
toVersionnointeger ≥ 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.

Parameters

NameInRequiredTypeDescription
loopquerynostring (up to 200 characters)
daysquerynointeger 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.

Responses

  • 200 {loopId, settings: {postsPerWeek, days, networks, stored, updatedAt}, week: {weekStart, settings, destinations, posts: [{slot, day, date, network, candidateKind, topic, status: waiting|approved|posted|rehearsed|declined|failed|not_staged, stagedActionId, actionType, note, excerpt, readback}]}}

PUT /v1/founder-post

Set the founder-post cadence (admin, audited): postsPerWeek (1–7), days (mon…sun), networks (linkedin|x|facebook; empty = every connected one); only the keys sent change

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
postsPerWeeknointeger 1–7
daysnoarray of one of "mon", "tue", "wed", "thu", "fri", "sat", "sun" (at least 1)
networksnoarray 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.

Responses

  • 200 {engines, mentionsSource, loop, cadence, promptSet, latest (design v2: with gapFixes, each citation gap's fix by prompt id: {briefActionId, briefStatus, query, draftActionId, draftStatus, draftRehearsed}, from the newest GEO page brief of the last 120 days that lists it and the page drafted from it), trend, inProgress, policy, searchConsoleExport, settings}

GET /v1/geo/settings

The tenant's GEO settings: whether its site is excluded from Google's AI features in Search Console

Scope: read.

Responses

  • 200 {settings: {excludedFromGoogleAi}}

PUT /v1/geo/settings

Say whether the site is excluded from Google's AI features in Search Console (admin; no API reports it). When it is, AI Overview and AI Mode answers are not counted as citation gaps and get no recommendation

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
excludedFromGoogleAiyesboolean

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Responses

  • 200 {versions}

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

One GEO prompt-set version

Scope: read.

Parameters

NameInRequiredTypeDescription
versionpathyesinteger ≥ 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.

Request body (application/json)

FieldRequiredTypeDescription
promptsyesarray of object (at least 1, at most 200)
  • text (string (at least 8 characters, up to 300 characters), required)
brandyesobject
  • name (string, required)
  • domains (array of string)
  • aliases (array of string)
competitorsnoarray of object (at most 10)
enginesnoarray of one of "claude", "openai", "gemini", "perplexity", "google_ai_overview", "google_ai_mode", "dataforseo", "bing_copilot", "dataforseo_ai_mode", "chatgpt_ui", "gemini_ui"
cadencenoone of "weekly", "daily"
budgetCentsPerRunnointeger ≥ 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.

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.

Parameters

NameInRequiredTypeDescription
promptIdpathyesstring

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.

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.

Responses

  • 200 {tenantId, plan, caps: {sendPerDay, sendPerRun, changePerRun, massAudienceThreshold, spendDailyCents, spendWeeklyCents}, spendCapsByCurrency, llm: {dailyCents, weeklyCents, dailySource, weeklySource}, alwaysEscalate}

Inbox

GET /v1/inbox

Approval inbox (list_pending_actions)

Scope: read.

Parameters

NameInRequiredTypeDescription
statusquerynostring
actionIdquerynostring (uuid)Only this tenant-owned action, filtered before the 100-row limit; missing or nonmatching actions return an empty list
loopquerynostringOnly 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.

Parameters

NameInRequiredTypeDescription
daysquerynointeger 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.

Parameters

NameInRequiredTypeDescription
kindpathyesstring

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.

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.

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
outcomeyesone of "sent", "quoted"
urlnostring (up to 2048 characters) or nullthe 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
skippedyesboolean

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
outcomeyesone of "sent", "quoted"
urlnostring (up to 2048 characters) or nullthe 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

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 or admin to turn it on, admin to turn it off, and wherever people sign in only a signed-in person turns it off.

Responses

  • 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 or admin to turn it on, admin to turn it off, and wherever people sign in only a signed-in person turns it off.

Parameters

NameInRequiredTypeDescription
channelpathyesone of "email", "ads", "cms", "indexnow", "crm", "social", "llm", "notifications", "research", "seo-data", "listening", "geo", "creative", "esp", "reviews", "product-analytics", "conversions"

Request body (application/json)

FieldRequiredTypeDescription
enabledyesboolean
reasonnostring (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.

Responses

  • 200 {host, posture, hostnames, tests, drafts, pending, terms, defaults, bounds, goalKinds, stripeGoalWindows}

POST /v1/landing-pages/hostnames

Claim a hostname you own (a subdomain such as try.example.com) for hosted landing-page tests (admin; audited; a paid plan or Alpha). Nothing reaches the host yet: answers the DNS records to add — the _heycmo-challenge TXT record that proves this workspace controls the hostname, and the CNAME to the CNAME target

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
hostnameyesstring

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Request body (application/json)

FieldRequiredTypeDescription
sourceActionIdyesstring (uuid)
hostnameIdyesstring (uuid)
goalUrlnostring (uri)
goalKindnoone of "link", "trial_started", "first_paid_invoice"
weightsnoobject
mdenonumber 0.01–0.3
outcomeWindowDaysnointeger 1–60at most 14 for a link goal, 30 for trial_started, 60 for first_paid_invoice
plannedDaysnointeger 14–90
maxVisitorsnointeger 1000–1000000
hypothesisnostring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
variantnostring

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.

Responses

  • 200 AI-authored specs with status

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

Retire a dynamic loop spec (admin)

Scope: admin.

Parameters

NameInRequiredTypeDescription
loopIdpathyesstring

Responses

  • 200 retired
  • 404 no such spec

Loops

GET /v1/loops

Loop catalog (list_loops)

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
paramsyesobject

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring
limitquerynointeger 1–20 default 10

Responses

  • 200 {loopId, runs: [{id, startedAt, finishedAt, outcome, firstRun, sensed, eligible, staged, actions: [{id, actionType, status, outcome, createdAt, decidedAt, executedAt, recovered: [{currency, cents}], readback? (C6: a social post's 48-hour numbers)}]}], trackRecord, windowDays, week? (C6: founder-post only — What will post this week, as GET /v1/founder-post)}

GET /v1/loops/{id}/runs

A loop's 20 most recent runs (list_loop_runs)

Scope: read.

Parameters

NameInRequiredTypeDescription
idpathyesstring

Responses

  • 200 {runs: [{runKey, startedAt, outcome, actionable, sensedCount, stagedCount, executedCount}]}

POST /v1/loops/{id}/run

Run a loop now (builtin, skill-compiled or dynamic). A call with an API key, from the CLI or from a dashboard session is a new run that senses again; dedupe and staged-action idempotency keys keep it from staging one signal twice. A call with an MCP OAuth token keeps one run per day (the runDate, default today UTC): a second call that day returns that run (resumed: true)

Scope: execute.

Parameters

NameInRequiredTypeDescription
idpathyesstring
runDatequerynostring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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.

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.

Responses

  • 200 register report

Marketing

GET /v1/marketing/operations

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

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.

Parameters

NameInRequiredTypeDescription
workIdpathyesstring (uuid)
taskIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring
stepIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
openquerynobooleanReturn 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.

Request body (application/json, required)

FieldRequiredTypeDescription
goalyesstring (at least 1 characters, up to 2000 characters)
reviewOnyesstring (date)
metricIdnostring (up to 200 characters)
tasksyesarray 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.

Request body (application/json, required)

FieldRequiredTypeDescription
goalyesstring (at least 1 characters, up to 2000 characters)
reviewOnyesstring (date)
metricIdnostring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json, required)

FieldRequiredTypeDescription
versionyesinteger ≥ 1
planyesobject
  • 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json, required)

FieldRequiredTypeDescription
versionyesinteger ≥ 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json, required)

FieldRequiredTypeDescription
versionyesinteger ≥ 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json, required)

FieldRequiredTypeDescription
versionyesinteger ≥ 1
taskIdyesstring
summaryyesstring
urlnostring (uri)
artifactHashnostring

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json, required)

FieldRequiredTypeDescription
versionyesinteger ≥ 1
noteyesstring
nextWorkyesstring

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)
taskIdpathyesstring

Request body (application/json, required)

FieldRequiredTypeDescription
versionyesinteger ≥ 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.

Responses

  • 200 {version (0 = none yet), state: {icpSegments, goals, channelPriorities, budget, competitors, doNotSay, messagingPillars}, updatedAt, updatedBy, source: api|strategy|playbook|null, changelog: [{fromVersion, toVersion, fields, source, sourceRef, reason, proposedBy, acceptedBy, createdAt}], listeningCompetitors: {competitors: [{name, domain}] (the listening settings' companies), watched (every name monitoring watches: those companies, then the competitor search terms), match, onlyInMarketingState, onlyInListening (compared with watched)}}

PUT /v1/marketing-state

Replace the named marketing-state fields; the others keep their value (admin, audited). A change bumps the version and adds a changelog row; an unchanged write changes nothing

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
expectedVersionnointeger ≥ 0refuse unless the record is at this version (0 = none yet)
reasonnostring (up to 500 characters)
icpSegmentsnoarray of object (at most 5)Each item: {id?, name, firmographics?: {industries[], employeesMin, employeesMax, regions[], notes}, pains[], disqualifiers[]}
goalsnoarray 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?}
channelPrioritiesnoarray of object (at most 12)Each item: {channel, label?, reason?}, most important first
budgetnoobject or null{currency, monthlyTotalCents, channels: [{channel, monthlyCents, targets: [{conversion, cpaCents?, roas?}]}], pacing: {rule: even|front_loaded|monthly_cap, tolerancePct}}; null clears it
competitorsnoarray 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
doNotSaynoarray of string (up to 80 characters) (at most 40)
messagingPillarsnoarray 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.

Request body (application/json)

FieldRequiredTypeDescription
expectedVersionnointeger ≥ 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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

Request body (application/json, required)

FieldRequiredTypeDescription
requestyesstring (at least 1 characters, up to 8000 characters)
sitenostring (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.

Responses

  • 200 delegation plan

POST /v1/mesh/council

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

Scope: read.

Responses

  • 200 council output

POST /v1/mesh/seo-audit

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

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.

Parameters

NameInRequiredTypeDescription
datequerynostring (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.

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.

Responses

  • 200 {generatedAt, metrics: [{id, label, definition, unit, window, source, freshnessSlaHours, trustRule, collected, perCurrency, currency, annotated, latest: {date, value, source, observedAt, currency} | null, trust: {status, freshness, warning}}], collection}; money is one metric per currency (<family>.<currency>), never summed across currencies

GET /v1/metrics/pnl

The Marketing P&L over the trailing 30 days, one row per currency (money is never added across currencies): new MRR (stripe.new_mrr_cents_30d), recovered — the email-driven bucket only (recovery.email_driven_cents_30d) — paid spend (paid.spend_cents_30d), HeyCMO's cost (heycmo.cost_cents_30d.usd, on the USD row only: it is metered in US cents), net, a CAC proxy ((paid spend + HeyCMO cost on USD) ÷ new paying subscriptions, organic included) and payback months (CAC proxy ÷ ARPA), both estimates. Each figure cites its registry id; a figure the registry does not hold is null (not measured yet), never 0. The 30 days end today: a figure measured more than 3 days before today is marked stale (its value and date kept) and nothing is derived from it

Scope: read.

Responses

  • 200 {generatedAt, windowDays, rows: [{currency, newMrr, recovered, paidSpend, heycmoCost, newPayingSubscriptions, arpa: {metricId, value, date, stale?: true}, net, cacProxy, paybackMonths: {value, from, missing, stale, formula}, notes}], heycmoCost}; a derived value is null when an input is missing or stale

GET /v1/metrics/targets

Each goal of the marketing state against the newest stored value of its own registry metric (same unit and currency by construction): met, ahead or behind by gap in the metric's unit (positive is the good side; a lower-is-better metric reads the other way), not_measured, or not_comparable (a money family without a currency, or an id HeyCMO does not measure), with the days left until its date

Scope: read.

Responses

  • 200 {generatedAt, targets: [{goalId, label, metricId, unit, currency, target, by, current: {metricId, value, date}, state, gap, daysLeft, direction, reason}]}; direction is the metric's good direction ('up', 'down' for lower is better, 'neutral'; null for an id HeyCMO does not measure)

GET /v1/metrics/funnel

The funnel: sessions → key events → trials → new paying subscriptions, one row per day between two UTC days (default the 30 days ending today), with how many trials and new paying subscriptions the attribution ledger joined to a visit; the latest complete 30 days as the funnel collector wrote them; and the readiness line "Platform-reported conversions exceed Stripe conversions by N×" when the ad platforms claim at least 10 conversions and twice Stripe's new subscriptions or more

Scope: read.

Parameters

NameInRequiredTypeDescription
fromquerynostring (date)
toquerynostring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring
daysquerynointeger 1–90
fromquerynostring (date)
toquerynostring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring
fromquerynostring (date)
toquerynostring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring
fromquerynostring (date)
toquerynostring (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.

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.

Parameters

NameInRequiredTypeDescription
daysquerynoone 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.

Parameters

NameInRequiredTypeDescription
conversionquerynoone of "trials_started", "paid_conversions"
daysquerynointeger 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.

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.

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.

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.

Parameters

NameInRequiredTypeDescription
limitquerynointeger 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.

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
maxStepsnointeger 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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring
stepIdpathyesstring
reviewIdquerynostring (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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring
stepIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring
stepIdpathyesstring
reviewIdpathyesstring (uuid)

Request body (application/json, required)

FieldRequiredTypeDescription
decisionyesone of "declined", "revoked"
reviewHashyesstring

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring
stepIdpathyesstring
reviewIdquerynostring (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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring
stepIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
runIdpathyesstring
stepIdpathyesstring
reviewIdpathyesstring (uuid)

Request body (application/json, required)

FieldRequiredTypeDescription
decisionyesone of "reviewed", "declined", "revoked"
reviewHashyesstring
reviewMaterialHashyesstring
acknowledgementVersionyesmust 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.

Responses

  • 200 {sourcePin, catalogHash, coverage, reviewedShards, operations}: definitions, implemented, bindingVersion, availability, inputSchema, outputSchema; partial coverage publishes no global operation total

GET /v1/operations/{operationId}

Inspect one reviewed operation and its registered binding contracts (read scope)

Scope: read.

Parameters

NameInRequiredTypeDescription
operationIdpathyesstring

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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. 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. HeyCMO operator only (an admin key of the operator workspace).

Request body (application/json)

FieldRequiredTypeDescription
kindyesone of "global", "channel", "tool"
targetnostringchannel name or governed tool name; omitted for global
enabledyesboolean
reasonnostring (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. 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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
reasonyesstring (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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring

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. 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. 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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
idpathyesstring

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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring

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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring

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. 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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring
channelpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
reasonyesstring (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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring
channelpathyesstring

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. 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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring (uuid)
actionIdpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
outcomeyesone of "accepted", "confirmed_not_accepted"
provideryesone of "resend", "postmark", "ses", "sendgrid"
externalRefnostring (at least 1 characters, up to 512 characters)required for accepted: the provider message id
acceptedAtnostring (date-time)required for accepted: acceptance time at or after the original attempt and not in the future
dryRunyesbooleanthe 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
reasonyesstring (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. 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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring
actionIdpathyesstring

Request body (application/json)

FieldRequiredTypeDescription
outcomeyesone of "applied", "not_applied"
externalRefnostring (up to 512 characters)the provider id of the applied write
reasonyesstring (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. 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. 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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
actionTypepathyesstring

Request body (application/json)

FieldRequiredTypeDescription
statusyesone of "allowed", "blocked"
maxPerRecipientnointeger 1–10
windowDaysnointeger 1–90
reasonyesstring (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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring

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. HeyCMO operator only (an admin key of the operator workspace).

Parameters

NameInRequiredTypeDescription
tenantIdpathyesstring

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.

Responses

  • 200 registry

POST /v1/playbooks/run

Start playbook (run_playbook)

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.

Request body (application/json)

FieldRequiredTypeDescription
providernoone of "posthog", "mixpanel", "amplitude"
eventsnoobject
  • 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.

Responses

  • 200 {configured, publicKey, limit, you: {devices: [{id, endpointHash, createdAt, lastSentAt, lastFailedAt, lastError}]} | null}

POST /v1/push/subscriptions

Register this device for approval pushes (a signed-in person, Clerk session; audited). The body is PushSubscription.toJSON(); the endpoint must be https on a public push-service host

Scope: approve.

Request body (application/json)

FieldRequiredTypeDescription
endpointyesstring (uri) (up to 2048 characters)
keysyesobject
  • 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (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.

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.

Parameters

NameInRequiredTypeDescription
providerquerynoone of "g2", "appstore", "googleplay"
statusquerynoone of "unanswered", "answered", "all"
limitquerynointeger 1–200

Responses

  • 200 {reviews: [{id, provider, rating, title, body, author, reply, proposedReplyActionId, flagged, canReply, …}]}

POST /v1/reviews/collect

Read every connected review source now through the governed reads (stopped by the reviews kill switch) and store new reviews

Scope: execute.

Responses

  • 200 {sources: [{provider, read, added, updated, flagged}], failures}
  • 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)

Request body (application/json)

FieldRequiredTypeDescription
replynostring
reasoningnostring (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.

Responses

  • 200 {packId, gate, ready, checks: [{id, ok, detail}], site, sources}

POST /v1/seo/readiness/probe

Run one governed live Search Console read (the readiness evidence), then return the readiness checks (execute)

Scope: execute.

Responses

  • 200 {probe: {ok, rows?, source?, error?}, …readiness}

GET /v1/seo/data-budget

Month-to-date DataForSEO spend against this tenant's monthly SEO data budget (E3.2)

Scope: read.

Responses

  • 200 {provider, configured, problem?, monthlyBudgetCents, source, spentMicroUsd, spentCents, remainingCents, calls}

PUT /v1/seo/data-budget

Set this tenant's monthly DataForSEO budget (admin; audited)

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
monthlyBudgetCentsyesinteger 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.

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.

Responses

  • 200 {tenant, identity, operator, billing, inbox, killSwitch, senderIdentity, features, firstValue, adChanges: {networks: [googleads|microsoftads]}|null}; B6: features = {geo, press, creative, landingPages}, each {available, reason: not_set_up|paid_plan|restricted|null}

Slack

GET /v1/slack

The HeyCMO Slack app (E4.7): whether it is configured and installed, the installed workspace (teamId, teamName), the approval channel, linked members, and whether the caller is linked (null for an API key)

Scope: read.

Responses

  • 200 {configured, installed, teamId, teamName, channelId, installedAt, linkedMembers, you: {linked} | null, redirectUri}

PUT /v1/slack/channel

Set the Slack channel approval notices go to (admin). Send the teamId GET /v1/slack returned: when another workspace is installed now, nothing is saved (409). Without teamId the channel is saved for the workspace installed when the request arrives

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
channelIdyesstring or nulla Slack channel id (C… or G…); null clears it
teamIdnostringoptional: 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

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.

Parameters

NameInRequiredTypeDescription
includequerynoone 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.

Parameters

NameInRequiredTypeDescription
seqpathyesinteger ≥ 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.

Request body (application/json)

FieldRequiredTypeDescription
marketingStatenoobjectany 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.

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.

Responses

  • 200 { total, byReason: { unsubscribed, bounced, complained, yours, erased, invalid, other }, addedThisWeek, sourcesThisWeek: [unsubscribe_link|resend|postmark|ses|sendgrid|team|erasure|address_check|other] }

POST /v1/suppressions

Add an address to the do-not-contact list (admin): nobody at it gets an email from HeyCMO. An address already listed is kept as it is. The address is hashed, never stored, logged or audited; the addition is audited as suppression_added

Scope: admin.

Request body (application/json, required)

FieldRequiredTypeDescription
addressyesstring

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.

Request body (application/json, required)

FieldRequiredTypeDescription
addressyesstring

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.

Parameters

NameInRequiredTypeDescription
keypathyesstring

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.

Responses

  • 200 {state, posture, request: {requestedAt, requestedBy, termsVersion, notifiedAt (W4-D2: when HeyCMO's operator was told of it)} | null, decision, reason, preconditions, wedge (independent recovery evidence checks and the approved dry-run action id), terms, sla: {businessDays, text} (W4-D2: how soon HeyCMO decides; null where no operator can approve), trial (the no-card trial, as GET /v1/billing/status; null when not on one), canRequest, canRevoke, history}

POST /v1/tenant/live-send/request

Request go-live (admin). Needs every configuration precondition and the recovery rehearsal wedge, plus the current terms version; the operator approves. W4-D2: the operator tenant is notified, and a running no-card trial's clock stops until the request is approved or revoked

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
acknowledgeTermsyesmust be true
termsVersionyesstring
reasonnostring (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.

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.

Responses

  • 200 {channels: [{channel, state, posture, adapters, readiness, request: {requestedAt, requestedBy, termsVersion, notifiedAt (W4-D2: when HeyCMO's operator was told of it)} | null, decision, reason, canRequest, canRevoke}], terms, dbApprovalsEnabled, history}

POST /v1/tenant/write-channels/{channel}/request

Request a write channel go live (admin). Needs every readiness check and the current terms; the operator approves (W4-D2: the operator tenant is notified)

Scope: admin.

Parameters

NameInRequiredTypeDescription
channelpathyesone of "ads", "cms", "indexnow", "crm", "social", "esp", "reviews"

Request body (application/json)

FieldRequiredTypeDescription
acknowledgeTermsyesmust be true
termsVersionyesstring
reasonnostring (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.

Parameters

NameInRequiredTypeDescription
channelpathyesstring

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.

Responses

  • 200 {platforms, authorizations: [{id, platform, adAccount, reference, paidForBy, recordedAt, revokedAt, ref}]}

PUT /v1/tenant/ad-political-authorizations

Record the tenant's authorisation to run political or social-issue ads on one Google Ads or Meta ad account (admin); revokes that account's earlier one

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
platformyesone of "googleads", "meta"
adAccountyesstringGoogle Ads customer id (10 digits) or Meta ad account id (act_ optional)
referenceyesstring (up to 200 characters)The platform's reference for the authorisation
paidForByyesstring (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.

Parameters

NameInRequiredTypeDescription
idpathyesstring

Responses

  • 200 {authorization}
  • 404 no such authorisation

GET /v1/tenant/notifications

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

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.

Request body (application/json)

FieldRequiredTypeDescription
emailRecipientsnoarray of string (email) (at most 10)
coalesceMinutesnointeger 1–1440
slackWebhookUrlnostring or nullhttps://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.

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.

Responses

  • 200 {ceiling, posture, evidence, consentBases, types, history}

PUT /v1/tenant/marketing-policy/{actionType}

Opt in or out of one marketing send type (admin). Opting in needs the consent basis for that recipient list and where its evidence lives

Scope: admin.

Parameters

NameInRequiredTypeDescription
actionTypepathyesone of "winback_send", "churn_intervention_send", "trial_conversion_send", "pql_upgrade_send", "expansion_upsell_send"

Request body (application/json)

FieldRequiredTypeDescription
enabledyesboolean
consentBasisnoone of "express_consent", "soft_opt_in", "legitimate_interest"
consentEvidencenostring (up to 500 characters)where the consent or balancing-test record lives
maxPerRecipientnointeger or null 1–10only ever tightens the platform cap
windowDaysnointeger or null 1–90
ctaUrlnostring or nullhttps 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.

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.

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.

Responses

  • 200 {tenantId, plan, caps: {sendPerDay, changePerRun, massAudienceThreshold, llmDailyCents, llmWeeklyCents: {ceiling, value, effective, usedToday?, ceilingSource?}}, spendCaps: [{currency, ceiling, value, effective, spent}], alwaysEscalate: {builtin, operator, tenant}}

PUT /v1/tenant/caps

A2: lower this workspace's caps (admin; audited caps_set). Body: sendPerDay, changePerRun, massAudienceThreshold, llmDailyCents, llmWeeklyCents (integers; null removes the value), spendCaps {<currency>: {daily, weekly} as decimal strings, or null}, alwaysEscalateExtra (conditions to add, or null), reason. Tighten only: a value above its ceiling is refused

Scope: admin.

Responses

  • 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.

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.

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.

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.

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.

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.

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.

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.

Parameters

NameInRequiredTypeDescription
statequerynoone of "waiting", "confirmed", "withdrawn", "changed"
cursorquerynostring (up to 512 characters)the page before's nextCursor
limitquerynointeger 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.

Parameters

NameInRequiredTypeDescription
idpathyesstring (uuid)the testimonial (GET /v1/testimonials id)

Request body (application/json)

FieldRequiredTypeDescription
permissionyesone of "confirmed", "withdrawn"
evidenceKindnoone of "customer_reply", "signed_form", "other"confirmed only (required): how the customer agreed
evidencenostring (up to 500 characters) or nullconfirmed (required): where the agreement is kept; withdrawn (optional): why. Never an e-mail address
expectedQuoteSha256nostring or nullconfirmed (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.

Responses

  • 200 {keyEvents: string[] | null, checks: named | ga4_key_events, recentDays, lookbackDays, reads}

PUT /v1/tracking/settings

Name the GA4 key events tracking QA checks (1 to 30 GA4 event names), or null to check the GA4 property's own key events (admin, audited without the names)

Scope: admin.

Request body (application/json)

FieldRequiredTypeDescription
keyEventsyesarray of string (at least 1 characters, up to 40 characters) (at least 1, at most 30) or nullEach 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

NameInRequiredTypeDescription
tenantIdpathyesstring

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

NameInRequiredTypeDescription
tenantIdpathyesstring

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

NameInRequiredTypeDescription
tenantIdpathyesstring
X-GitHub-Eventheaderyesstring
X-GitHub-Deliveryheaderyesstring
X-Hub-Signature-256headeryesstring

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

NameInRequiredTypeDescription
tenantIdpathyesstring

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

NameInRequiredTypeDescription
tenantIdpathyesstring

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.

Responses

  • 200 {tenant, identity}