API reference
Base URL https://api.funnelkeeper.com · Version 0.2.0 · Machine-readable spec at /openapi.json.
Authentication: Authorization: Bearer <token> — an account API key (fk_live_…), a dashboard session, or the operator token. Public endpoints are marked.
auth
POST /auth/signup
Public — no authentication.
Create an account. Self-serve signup. Sends a verification email; the account cannot log in until verified. Responds identically whether or not the email is already registered. Optional browser attribution joins the server-side signup to Opinly and deduplicates Meta Pixel + CAPI.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
password | string | yes | At least 10 characters. |
anonId | string | no | Opinly pixel anonymous id (window.opinly.anonId). Joins the server-side sign_up to the browser visit. Ignored if missing or malformed. |
meta | object | no |
200 — Verification email sent (or already registered).
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
status | verification_sent | yes |
429 — Rate limited.
POST /auth/lead-prompt
Public — no authentication.
Email Cursor prompt & growth setup guide to a mobile visitor. Captures a lead and emails the user the exact copy-paste Cursor prompt and desktop onboarding link.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
prompt_type | growth-engine · payback · mcp | no | Which prompt and guide to send to the recipient. |
meta | object | no |
200 — Prompt sent to inbox
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
message | string | yes |
429 — Rate limited.
POST /auth/agent-signup
Public — no authentication.
Create an account from an agent (CLI / MCP). Public, rate-limited. Creates a tenant plus owner user, returns a working API key immediately (no email-verification click), and a single-use claim URL the human opens later to set a password. Email already registered → 409 (generic wording). The account is fully functional from the moment the key is returned.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
name | string | no |
200 — Account created; API key shown once.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
account_id | string | yes | |
api_key | string | yes | The full API key (fk_live_…). Shown exactly once — store it now. |
claim_url | string | yes | Single-use link the human opens to set a password and take over the account. |
claim_expires_at | string | yes |
409 — Email already has a FunnelKeeper login.
429 — Rate limited.
POST /auth/claim-link
Re-issue the human claim URL. Authenticated. Issues a fresh 7-day single-use invite token for the account’s unclaimed owner (password still the ’!’ sentinel). 409 if the owner already set a password — use password reset instead.
200 — Fresh claim URL.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
claim_url | string | yes | |
expires_at | string | yes |
409 — Account already claimed.
GET /auth/verify
Public — no authentication.
Verify an email address. Consumes the emailed token. On success redirects (302) to the dashboard login; from an API client treat any 2xx/3xx as verified.
| Parameter | In | Type | Required |
|---|---|---|---|
token | query | string | yes |
302 — Verified; redirecting to the dashboard.
400 — Invalid or expired token.
POST /auth/login
Public — no authentication.
Log in.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
password | string | no |
200 — Session created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | Session bearer token (fk_sess_…). Expires after 7 idle days. |
expires_at | string | yes | |
account | object | yes | |
accounts | object[] | no | The person’s accepted memberships (for the account switcher). |
is_new | boolean | no | True when this Google completion created the account (vs a returning login). Email/password login omits it. |
401 — Wrong credentials.
403 — Email not yet verified.
GET /auth/google/start
Public — no authentication.
Start Google sign-in. Public. 302 to Google (openid/email/profile only — this is login, not the GA4/GTM connect grant). After consent Google returns to /auth/google/callback. A new Google identity becomes a tenant; an existing email is linked and signed in (Google has verified the address, so email confirmation is skipped).
302 — Redirecting to Google.
503 — GOOGLE_OAUTH_CLIENT_ID/SECRET are not set.
GET /auth/google/callback
Public — no authentication.
Google sign-in callback. Public. Exchanges the authorization code, finds or creates the user, issues a one-time ticket, and redirects to the dashboard at #/auth/google/
302 — Redirecting to the dashboard to complete sign-in.
400 — Invalid state or Google error.
POST /auth/google/complete
Public — no authentication.
Finish Google sign-in. Public. Consumes the one-time ticket from the dashboard redirect and returns a session — same payload as POST /auth/login.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | The one-time ticket from the #/auth/google/ |
200 — Session created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | Session bearer token (fk_sess_…). Expires after 7 idle days. |
expires_at | string | yes | |
account | object | yes | |
accounts | object[] | no | The person’s accepted memberships (for the account switcher). |
is_new | boolean | no | True when this Google completion created the account (vs a returning login). Email/password login omits it. |
400 — Invalid or expired ticket.
429 — Rate limited.
POST /auth/logout
Log out (revoke this session).
200 — Session revoked.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
GET /auth/me
Who am I.
200 — The authenticated account.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | The ACCOUNT (tenant) id — projects and API keys hang off this. |
email | string | yes | The authenticated person’s email. |
role | customer · operator | yes | The tenant’s role. |
member_role | owner · member | no | The person’s role within the account. |
name | string \ | null | no |
account_name | string | no | The tenant’s display name (the switcher label). |
verified | boolean | yes | |
created_at | string | yes | |
platform_admin | boolean | no | True when this person is a platform admin (staff domain or explicit grant). |
POST /auth/me
Update your profile. Update the signed-in person’s display name and/or password. Password change verifies the current one first and revokes all other sessions. API-key / operator-token contexts (no person) get 400 — sign in with a password to edit a profile.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string \ | null | no |
current_password | string | no | Required when new_password is set. |
new_password | string | no |
200 — Updated; the new account payload (same shape as /auth/login).
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | The ACCOUNT (tenant) id — projects and API keys hang off this. |
email | string | yes | The authenticated person’s email. |
role | customer · operator | yes | The tenant’s role. |
member_role | owner · member | no | The person’s role within the account. |
name | string \ | null | no |
account_name | string | no | The tenant’s display name (the switcher label). |
verified | boolean | yes | |
created_at | string | yes | |
platform_admin | boolean | no | True when this person is a platform admin (staff domain or explicit grant). |
400 — No person attached to this auth context, or nothing to update.
401 — Current password wrong.
GET /auth/accounts
List my workspaces. Accepted, non-disabled memberships for the signed-in person. API-key contexts return the current tenant only.
200 — Workspaces.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
name | string | yes | |
role | customer · operator | yes | |
member_role | owner · member | yes |
POST /auth/accounts
Create a workspace. Session-only. Mints a new tenant, makes the caller its owner, and switches the current session onto it.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes |
200 — Created and switched.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
account | object | yes | |
accounts | object[] | yes |
400 — Not a session.
POST /auth/switch-account
Switch the active workspace. Session-only. Repoints this session at another of the person’s accepted memberships. Other tabs sharing the token follow.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
account_id | string | yes |
200 — Switched.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
account | object | yes | |
accounts | object[] | yes |
400 — Not a session.
404 — No such membership.
GET /auth/keys
List API keys.
200 — Keys (prefixes only).
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
name | string | yes | |
prefix | string | yes | |
created_at | string | yes | |
last_used_at | string \ | null | yes |
POST /auth/keys
Create an API key. The response contains the full key exactly once.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no |
200 — Created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
name | string | yes | |
key | string | yes | The full API key (fk_live_…). Shown exactly once — store it now. |
POST /auth/keys/{id}/revoke
Revoke an API key.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Revoked.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
team
GET /team
List the account’s team.
200 — Members, oldest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
email | string | yes | |
name | string \ | null | yes |
member_role | owner · member | yes | |
status | active · invited · disabled | yes | |
created_at | string | yes | |
last_seen_at | string \ | null | yes |
POST /team/invites
Invite a person to the account. Owners only. Returns the single-use invite URL (also emailed when an email service is configured) — valid 7 days. A new email becomes a user with no password; an existing FunnelKeeper login becomes a pending membership on this account (409 only if they are already a member here).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
member_role | owner · member | no |
200 — Invited.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
user_id | string | yes | |
invite_url | string | yes | |
expires_in_hours | integer | yes | |
already_registered | boolean | yes |
403 — Not an owner.
409 — Already a member of this account.
POST /team/{id}/disable
Disable a team member. Owners only. Revokes their sessions immediately. You can’t disable yourself, and the last active owner can’t be disabled.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
400 — Guard rail refused it.
403 — Not an owner.
POST /team/{id}/enable
Re-enable a team member. Owners only.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
400 — Guard rail refused it.
403 — Not an owner.
POST /team/{id}/role
Change a member’s role. Owners only. The last active owner can’t be demoted.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
member_role | owner · member | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Changed.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
400 — Guard rail refused it.
GET /auth/invite
Public — no authentication.
Peek at an invite. Public. Does not consume the token. Tells the dashboard whether the invitee already has a password.
| Parameter | In | Type | Required |
|---|---|---|---|
token | query | string | yes |
200 — Invite is valid.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
account_name | string | yes | |
needs_password | boolean | yes |
400 — Invalid or expired invite.
POST /auth/accept-invite
Public — no authentication.
Accept an invite. Public — the single-use invite token is the credential. New people set a password; existing logins just activate the membership. Returns a logged-in session on the invited account.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | |
password | string | no | Required when the invitee has no password yet. Existing logins omit it. |
name | string | no |
200 — Joined; session created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | Session bearer token (fk_sess_…). Expires after 7 idle days. |
expires_at | string | yes | |
account | object | yes | |
accounts | object[] | no | The person’s accepted memberships (for the account switcher). |
is_new | boolean | no | True when this Google completion created the account (vs a returning login). Email/password login omits it. |
400 — Invalid or expired invite.
portfolio
GET /portfolio
All projects in your account. One row per project: spend, traffic, revenue, CAC, LTV:CAC, gate status, pending Keeper cards. Operators see every account.
200 — Projects.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | string | yes | |
slug | string | yes | |
name | string | yes | |
stage | validation · live · killed | yes | |
currency | string | yes | |
domain | string \ | null | no |
gate_result | pass · fail · pivot | yes | |
gate_due_date | string \ | null | yes |
spend_7d_cents | integer | yes | |
spend_30d_cents | integer | yes | |
spend_prev_30d_cents | integer | yes | |
visits_30d | integer | yes | |
leads_30d | integer | yes | |
revenue_30d_cents | integer | yes | |
revenue_prev_30d_cents | integer | yes | |
customers_30d | integer | yes | |
cac_cents | integer \ | null | yes |
ltv_cents | integer \ | null | yes |
ltv_cac | number \ | null | yes |
pending_cards | integer | yes |
POST /products
Create a project.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
slug | string | yes | |
domain | string | no | |
currency | string | no |
200 — Created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
slug | string | yes | |
name | string | yes | |
currency | string | yes | |
stage | string | yes |
409 — Slug taken.
POST /products/{slug}
Update a project’s display fields. Edits name, currency, and/or domain. The slug is immutable — it’s in URLs, the CLI, MCP tool args, and audit subject_refs, so renaming it would orphan every external reference. At least one field is required.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | |
currency | string | no | |
domain | string \ | null | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Updated.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
slug | string | yes | |
name | string | yes | |
currency | string | yes | |
domain | string \ | null | yes |
400 — Nothing to update.
404 — Not yours or doesn’t exist.
GET /products/{slug}
One project’s portfolio row.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The row.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | string | yes | |
slug | string | yes | |
name | string | yes | |
stage | validation · live · killed | yes | |
currency | string | yes | |
domain | string \ | null | no |
gate_result | pass · fail · pivot | yes | |
gate_due_date | string \ | null | yes |
spend_7d_cents | integer | yes | |
spend_30d_cents | integer | yes | |
spend_prev_30d_cents | integer | yes | |
visits_30d | integer | yes | |
leads_30d | integer | yes | |
revenue_30d_cents | integer | yes | |
revenue_prev_30d_cents | integer | yes | |
customers_30d | integer | yes | |
cac_cents | integer \ | null | yes |
ltv_cents | integer \ | null | yes |
ltv_cac | number \ | null | yes |
pending_cards | integer | yes |
404 — Not yours or doesn’t exist.
GET /products/{slug}/funnel
Funnel stage × channel volumes.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Rows.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
stage | string | yes | |
channel | string \ | null | yes |
events | integer | yes | |
volume | integer | yes |
GET /products/{slug}/payback
Cohort payback curves + per-channel CAC.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — cohorts: cumulative revenue per cohort-day; cac: 90-day spend / first-touch customers per channel.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
cohorts | object[] | yes | |
cac | object[] | yes |
queue
GET /queue
Pending Keeper cards. The HITL spine. The Keeper proposes; only a human resolution moves a card. Nothing changes spend without a tap.
200 — Cards.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string \ | null | yes |
card_type | proposal · insight · alert · gate_result · policy_block | yes | |
headline | string | yes | |
metric_line | string | yes | |
detail | string \ | null | yes |
proposed_by | keeper · system | yes | |
action_spec | object \ | null | yes |
status | string | yes | |
created_at | string | yes | |
expires_at | string \ | null | yes |
POST /queue/{id}/approve
Approve a card. Approving a spend proposal is policy-checked (daily caps, network walls, portfolio ceiling) and can change ad spend. resolved_by records the authenticated human.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
POST /queue/{id}/reject
Dismiss a card. Removes the card from the queue; recorded with the authenticated actor.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
POST /queue/{id}/snooze
Snooze a card. Reappears after snoozeHours (default 24).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
POST /queue/{id}/restore
Restore a snoozed/expired card. Only ever returns a card to pending; can never resolve one.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
connections
GET /connections
Integration inventory. Status, redacted config, last sync/error, live sync progress and backfill depth per source. Secrets never appear here.
200 — Connections.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
kind | string | yes | |
status | pending · active · error · disabled | yes | |
config | object \ | null | yes |
last_sync_at | string \ | null | yes |
last_error | string \ | null | yes |
backfill_days | integer | yes | How deep this source has read, in days: 0 never, then 30, 365 and finally 3650 (everything) as the staged backfill climbs. Capped by the plan’s history window. |
syncing | boolean | yes | A run is in progress right now. Poll this endpoint while it is true — the numbers behind the other reads are still moving. Stamps older than 15 minutes are treated as dead processes and read false. |
sync_phase | string \ | null | yes |
product_slug | string | yes |
POST /connect/google/start
Begin connecting Google sources. Returns an authorization URL for a HUMAN to open (agents: hand it to your user). One Google grant serves ga4, gtm, google_ads, and (when GOOGLE_GSC_OAUTH=1) Search Console together. Then poll /connect/google/status.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
kinds | ga4 · gtm · google_ads · gsc[] | yes | |
client | dashboard · cli · mcp · api | no |
200 — The URL and the state to poll with.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
state | string | yes | |
auth_url | string | yes | |
expires_at | string | yes |
GET /connect/google/status
Poll an in-progress Google connection.
| Parameter | In | Type | Required |
|---|---|---|---|
state | query | string | yes |
200 — pending | complete (with entity options for the select step) | error.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
status | pending · complete · error | yes | |
product_slug | string | yes | |
error | string | no | |
results | object[] | no | |
options | object | no |
POST /connect/google/select
Finish a Google connection. Choose which GA4 property / GTM container / Ads customer to track, from the options in the status payload. ads_customer_id is the plain customer id (dashes are stripped); the manager to authenticate through is taken from the discovered list, so you never pass login-customer-id yourself.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
state | string | yes | |
ga4_property_id | string | no | |
gtm_container_path | string | no | |
ads_customer_id | string | no | |
gsc_site_url | string | no |
200 — Activated.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
activated | string[] | yes |
POST /connect/meta/start
Begin connecting Meta sources. Returns an authorization URL for a HUMAN to open. One Meta OAuth grant serves Meta Ads and/or Meta Social (Facebook Pages & Instagram) together. Then poll /connect/meta/status.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
kinds | meta_ads · meta_social[] | no | |
client | dashboard · cli · mcp · api | no |
200 — The URL and the state to poll with.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
state | string | yes | |
auth_url | string | yes | |
expires_at | string | yes |
GET /connect/meta/status
Poll an in-progress Meta connection.
| Parameter | In | Type | Required |
|---|---|---|---|
state | query | string | yes |
200 — pending | complete (with entity options for the select step) | error.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
status | pending · complete · error | yes | |
product_slug | string | yes | |
error | string | no | |
results | object[] | no | |
options | object | no |
POST /connect/meta/select
Finish a Meta connection. Choose which Meta Ad Account and/or Facebook Page to track, from the options in the status payload.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
state | string | yes | |
ad_account_id | string | no | |
page_id | string | no |
200 — Activated.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
activated | string[] | yes |
POST /connect/google/service-account/start
Begin a service-account Google connection. The agent-automatable path for GA4, GTM and Search Console. Returns a per-account service-account email. An agent with Admin on the client’s property/container (or Search Console Users) grants that email itself; otherwise the human pastes it into GA4/GTM/Search Console Admin. Then POST /connect/google/service-account/verify. Google Ads is not on this path — use /connect/google/start.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
kinds | ga4 · gtm · gsc[] | yes |
200 — The email to grant, plus per-kind instructions.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
sa_email | string | yes | |
kinds | ga4 · gtm · gsc[] | yes | |
instructions | object | yes |
POST /connect/google/service-account/verify
Finish a service-account Google connection. Without ids: list the GA4 properties, GTM containers and Search Console sites now visible to the service account (same options shape as /connect/google/status). With ids: a live Data API / Tag Manager / Search Console read proves the grant landed, then the connection goes active and a backfill starts.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
ga4_property_id | string | no | |
gtm_container_path | string | no | |
gsc_site_url | string | no |
200 — Discovered options, or activated kinds.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
status | complete | no | |
ok | true | no | |
sa_email | string | yes | |
activated | string[] | no | |
options | object | no |
POST /products/{slug}/connections/semrush
Connect SEMrush. Stores your SEMrush API key encrypted. Never test-called — calls burn your units; snapshots run weekly.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | |
domain | string | yes | |
database | string | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/clarity
Connect Microsoft Clarity. Stores your Clarity Data Export API token encrypted and the project id in config. The daily job samples session recordings (playback URLs, never video) and one URL-grain insights snapshot (rage/dead clicks). Data Export is limited to 10 requests per project per day.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
api_token | string | yes | Clarity Data Export JWT. Generated by a project admin under Settings → Data Export. Stored encrypted; never returned. |
project_id | string | yes | The Clarity project id, used to build playback and recordings-list URLs. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/opinly
Connect Opinly. Stores your Opinly API key encrypted and the company id in config. The daily job snapshots AI-search (GEO) visibility, site-audit health, tracked keyword ranks and competitor gaps into seo_snapshots. Sync now is allowed — these are stored company-scoped reads, not the metered live-research tools.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | Opinly API key from Settings → Developers (sk-…). Stored encrypted; never returned. |
company_id | string | no | Opinly company id. Optional when the key can see only one company; required when it can see several. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/bing
Connect Bing Webmaster. Stores your Bing Webmaster API key encrypted and the site URL in config. The daily job reads query and page stats into search_queries. Sync now is allowed — the API is not unit-metered.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | Bing Webmaster Tools API key from Settings → API Access. Stored encrypted; never returned. |
site_url | string | yes | The verified site URL exactly as it appears in Bing Webmaster (https://example.com/). |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/ahrefs
Connect Ahrefs. Stores your Ahrefs API v3 token encrypted and the target domain in config. Snapshots run weekly (units deplete) — domain rating, backlinks and top organic keywords. Not available as Sync now.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | Ahrefs API v3 token. Stored encrypted; never returned. |
domain | string | yes | The domain to track (example.com), without a scheme. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/meta-ads
Connect Meta Ads. Stores a Meta access token encrypted and the chosen ad account in config. Adult-adjacent products are refused (policy wall). Sync now is allowed — Insights is not unit-metered.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
access_token | string | yes | Meta user or system-user token with ads_read. Stored encrypted; never returned. |
ad_account_id | string | no | Numeric ad account id (no act_ prefix). Omit to discover, then reconnect with one. |
campaign_name_prefixes | string[] | no | Only ingest Meta campaigns whose names start with one of these prefixes. Empty = all campaigns. Shared ad accounts use this so foreign spend does not land on the product. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes | |
accounts | object[] | no |
POST /products/{slug}/connections/meta-ads/campaign-filter
Filter Meta Ads campaigns by name prefix. Sets campaign_name_prefixes on the Meta Ads connection. Only matching campaigns are ingested on the next sync; non-matching spend already stored for that ad account is dropped. Empty array clears the filter. Use this when one Meta ad account holds campaigns for more than one product.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_name_prefixes | string[] | yes | Only ingest Meta campaigns whose names start with one of these prefixes. Empty = all campaigns. Shared ad accounts use this so foreign spend does not land on the product. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
campaign_name_prefixes | string[] | yes | Only ingest Meta campaigns whose names start with one of these prefixes. Empty = all campaigns. Shared ad accounts use this so foreign spend does not land on the product. |
404 — No Meta Ads connection yet.
POST /products/{slug}/connections/linkedin-ads
Connect LinkedIn Ads. Stores a LinkedIn access token encrypted and the chosen ad account in config. Campaign spend lands in spend_records (channel paid_linkedin).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
access_token | string | yes | LinkedIn token with r_ads and r_ads_reporting. Stored encrypted; never returned. |
ad_account_id | string | no | Numeric sponsored account id. Omit to discover. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes | |
accounts | object[] | no |
POST /products/{slug}/connections/meta-social
Connect Meta Social. Stores a Meta token encrypted and the chosen Facebook Page (plus linked Instagram, if any). Daily snapshots and recent posts land in social_snapshots / social_posts.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
access_token | string | yes | Meta user token with pages_show_list and pages_read_engagement. Stored encrypted. |
page_id | string | no | Facebook Page id. Omit to discover. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes | |
pages | object[] | no |
POST /products/{slug}/connections/x-social
Connect X Social. Stores an X Bearer token encrypted and the username. Daily public_metrics and recent tweets land in social_snapshots / social_posts.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
bearer_token | string | yes | X API v2 Bearer token. Stored encrypted; never returned. |
username | string | yes | X username without @. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/stripe
Connect Stripe. Stores a Stripe Restricted or Secret API key encrypted. Successful charges become converted/payment events plus revenue rows; refunds and open/lost disputes write negative ledger rows. Sync now is allowed — Stripe is rate-limited, not unit-metered. Paid invoices already create a Charge, so invoices are not ingested separately.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | Stripe Restricted or Secret API key (rk_live_… / sk_live_…, or the test equivalents). Prefer a Restricted key with Charges, Customers, Refunds and Disputes read. Stored encrypted; never returned. |
default_stage | converted · payment | no | Funnel stage for a successful charge. converted is the usual bottom-of-funnel money event; payment is the later stage when you already map converted elsewhere. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/posthog
Connect PostHog. Stores your PostHog personal API key encrypted; the project id and host go in config. The daily job reads sessions and mapped events over HogQL (the funnel half) and samples session replays for friction (the recordings half). If GA4 is also connected it keeps ownership of visit/engaged traffic, and PostHog contributes only mapped events and replay — two sources counting the same sessions would double them.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | PostHog personal API key, scoped to query:read (funnel events) and, for replay, session_recording:read. Stored encrypted; never returned. |
project_id | string | yes | The PostHog project id — the number in your project URL. |
host | string | no | PostHog origin. Defaults to https://us.posthog.com; use https://eu.posthog.com for EU cloud, or your own origin when self-hosted. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/posthog/event-maps
Map PostHog events onto funnel stages. Replaces the PostHog connection’s event map. Each mapped event is ingested as daily aggregate stage rows, so the funnel (and conversions bound to source ‘posthog’) can be built from PostHog events. Map each stage from ONE source — a stage fed by both PostHog and GA4 double-counts.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
event_maps | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
event_maps | object[] | yes |
404 — No PostHog connection yet — connect it first.
POST /products/{slug}/connections/{kind}/test
Test a connection.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
kind | path | ga4 · gtm · google_ads · gsc · semrush · mysql · postgres · mongo · clarity · posthog · opinly · bing · ahrefs · meta_ads · linkedin_ads · meta_social · x_social · stripe | yes |
200 — ok + human-readable detail.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | boolean | yes | |
detail | string | yes |
POST /products/{slug}/connections/{kind}/sync
Sync a connection now. Runs the source immediately instead of waiting for the sweep (hourly for databases, daily for GA4 and PostHog), and clears any backoff so a corrected connection recovers at once. A source that is still backfilling climbs the staged ladder here: 30 days first so the dashboard populates, then a year, then everything, one pass at a time (each pass refreshes the views, so history deepens while you watch). Once it has all the history the plan allows, each run reads the last 7 days (3 for GA4 and PostHog) — ingest is idempotent, so windows may overlap. Returns as soon as the run starts: poll GET /connections for syncing, sync_phase, status, last_sync_at, last_error and backfill_days. GTM is included and re-runs its tag audit. Quota-bound sources are excluded (SEMrush and Ahrefs units deplete; Clarity allows ten Data Export calls per project per day). Opinly, Search Console and Bing Webmaster are included.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
kind | path | mysql · postgres · mongo · ga4 · posthog · opinly · gsc · bing · meta_ads · linkedin_ads · meta_social · x_social · stripe | yes |
200 — Started.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
started | boolean | yes | False when a sync for this connection was already running — that one is left alone. |
days | integer | yes | Lookback window the first pass uses: the next rung of the backfill ladder (30, 365, then everything), or 7 (3 for GA4 and PostHog) once the ladder is finished. |
detail | string | yes |
400 — That kind cannot be synced on demand.
404 — No connection of that kind.
409 — The connection is disabled.
POST /products/{slug}/connections/{kind}/disconnect
Disconnect a source and delete its credentials. Removes the connection and deletes the stored credential, stopping all collection from that source. Distinct from disable, which parks a connection but keeps its credential. One Google grant serves ga4, gtm and google_ads, so the credential is deleted only once no connection still points at it; for Google the refresh token is handed back to Google’s revocation endpoint first, so the grant also disappears from the customer’s Google account. Already-ingested events and transactions are untouched — they are facts, and this is a source, not a retention control.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
kind | path | string | yes |
200 — Disconnected.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
credential_deleted | boolean | yes | False when the credential is shared and another connection still uses it. |
revoked_upstream | boolean | yes | Google accepted the token revocation. False for non-Google sources, and when the customer had already revoked it from their Google account. |
404 — No connection of that kind.
POST /products/{slug}/detect-integrations
Auto-detect active tools and tracking on a project’s website. Fetches the project’s website (or the URL supplied in the body) and inspects its HTML/scripts to detect active analytics (GA4, PostHog, Opinly, Plausible, Fathom, Mixpanel, Amplitude, Heap), tag managers (GTM, Segment), advertising pixels (Google Ads, Meta Pixel, TikTok, LinkedIn, Pinterest, Twitter/X), session recording tools (Microsoft Clarity, Hotjar), first-party FunnelKeeper tracking, payment systems (Stripe), CRMs (Intercom, HubSpot, Zendesk), and platforms (Shopify, WooCommerce, WordPress, Webflow, Next.js). Cross-references results with existing connections in FunnelKeeper to identify what is already connected vs. what is ready to be connected.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | no | Website URL to scan (e.g. ‘https://motormerchants.com.au’). If omitted on project-scoped endpoint, defaults to project domain or landing page. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Detection results.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | The requested scan URL. |
final_url | string | yes | The resolved URL after redirects. |
domain | string | yes | The website domain. |
detected_count | integer | yes | Total number of detected tools and integrations. |
integrations | object[] | yes | List of detected tools and tracking tags. |
platform | object \ | null | yes |
summary | string | yes | One-sentence summary of findings. |
400 — Invalid URL or project has no domain configured.
404 — No such project.
POST /tools/detect-integrations
Auto-detect active tools and tracking on any website URL. Inspects any public website URL to auto-detect active analytics, tag managers, pixels, CRMs, payments, and tracking scripts before or during onboarding.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes |
200 — Detection results.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | The requested scan URL. |
final_url | string | yes | The resolved URL after redirects. |
domain | string | yes | The website domain. |
detected_count | integer | yes | Total number of detected tools and integrations. |
integrations | object[] | yes | List of detected tools and tracking tags. |
platform | object \ | null | yes |
summary | string | yes | One-sentence summary of findings. |
400 — Invalid URL or host.
POST /products/{slug}/connections/ga4/event-maps
Map GA4 events onto funnel stages. Replaces the GA4 connection’s event map. Each mapped event is ingested daily as aggregate stage rows, so the funnel (and conversions bound to source ‘ga4’) can be built from GA4 events. Map each stage from ONE source — a stage fed by both GA4 and MySQL double-counts.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
event_maps | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
event_maps | object[] | yes |
404 — No GA4 connection yet — connect Google first.
POST /products/{slug}/connections/mysql
Connect MySQL. Upserts the MySQL connection. Tenants pass credentials (encrypted at rest — use a read-only user, ideally on a replica); operators may instead pass env-var NAMES resolved on the API host. Plus event_maps — the data-not-code mapping from source tables to funnel stages, identities, attribution channels and revenue.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
credentials | object | no | Tenant path: the database credentials, encrypted at rest. Mutually exclusive with the *_env fields. |
host_env | string | no | The NAME of an environment variable on the API host — never the value. |
port_env | string | no | The NAME of an environment variable on the API host — never the value. |
user_env | string | no | The NAME of an environment variable on the API host — never the value. |
password_env | string | no | The NAME of an environment variable on the API host — never the value. |
database_env | string | no | The NAME of an environment variable on the API host — never the value. |
event_maps | object[] | no | |
status | pending · active | no | ‘active’ syncs on the next hourly run; ‘pending’ stages the config without syncing. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Configured.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes | |
status | string | yes |
403 — The *_env path is operator-only.
POST /products/{slug}/connections/mysql/event-maps
Update the MySQL event maps. Replaces the connection’s table→stage maps without touching credentials. Each map may carry identity columns (stitching), attribution columns (channels), and a revenue column (the dynamic per-row values ‘transaction’-mode conversions read).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
event_maps | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
event_maps | object[] | yes |
404 — No MySQL connection yet.
POST /products/{slug}/connections/mysql/suggest-mappings
Suggest MySQL event maps. Reads the connected database (tables, columns, 3 sample rows) and returns proposed event_maps. source=preset matches 2–3 common templates by column-name heuristics; source=ai sends the schema to the model. Pass tables to restrict which tables are described and sent. Proposals only — nothing is saved. Review then POST /connections/mysql/event-maps.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
source | preset · ai | yes | ‘preset’ returns applicable common templates; ‘ai’ asks the model to infer maps from the live schema + 3 sample rows. |
preset_id | string | no | When source=preset, optionally return just this template’s maps as suggestions. Omit to list every applicable preset. |
tables | string[] | no | Optional subset of tables to describe and send to the model. Omit to use the first 30. Empty array is rejected on the AI path. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Proposed maps.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
source | preset · ai | yes | |
model | string | no | OpenRouter model id — present on the AI path. |
schema_summary | object | yes | |
suggestions | object[] | yes | Proposed maps for the AI path, or for a specific preset_id. Empty when source=preset and preset_id is omitted — use presets then. |
presets | object[] | no | Applicable common templates (source=preset). Hidden templates that did not match the schema. |
404 — No MySQL connection, or preset_id does not fit.
409 — Connection exists but has no credentials yet.
503 — AI path and OPENROUTER_API_KEY is unset.
POST /products/{slug}/connections/postgres
Connect Postgres. Upserts the Postgres connection (tenant-only). Pass credentials (encrypted at rest — use a read-only role, ideally on a replica) plus event_maps — the data-not-code mapping from source tables to funnel stages, identities, attribution channels and revenue. Each map may set schema to reach a non-public schema.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
credentials | object | yes | Stored as one encrypted blob in account_credentials; never in connection config, never readable back. |
event_maps | object[] | no | |
status | pending · active | no | ‘active’ syncs on the next hourly run; ‘pending’ stages the config without syncing. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Configured.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes | |
status | string | yes |
503 — Credential encryption is not configured.
POST /products/{slug}/connections/postgres/event-maps
Update the Postgres event maps. Replaces the connection’s table→stage maps without touching credentials. Same shape as MySQL event maps, plus an optional per-map schema.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
event_maps | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
event_maps | object[] | yes |
404 — No Postgres connection yet.
POST /products/{slug}/connections/mongo
Connect MongoDB. Upserts the MongoDB connection (tenant-only). Pass credentials with a single connection string uri (encrypted at rest — SRV, replica sets, TLS and authSource all live in the URI; use a read-only user) plus event_maps — collection-based maps from source documents to funnel stages, identities, attribution channels and revenue. Field paths may use dot notation to reach nested values.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
credentials | object | yes | Stored as one encrypted blob in account_credentials; never in connection config, never readable back. |
event_maps | object[] | no | |
status | pending · active | no | ‘active’ syncs on the next hourly run; ‘pending’ stages the config without syncing. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Configured.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes | |
status | string | yes |
503 — Credential encryption is not configured.
POST /products/{slug}/connections/mongo/event-maps
Update the MongoDB event maps. Replaces the connection’s collection→stage maps without touching credentials. Each map may carry identity field paths (stitching), attribution field paths (channels), and a revenue field path (the dynamic per-row values ‘transaction’-mode conversions read).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
event_maps | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
event_maps | object[] | yes |
404 — No MongoDB connection yet.
GET /products/{slug}/connections/google-ads/url-suffix
Google Ads tracking-parameter status. Reads the account’s current final URL suffix and auto-tagging setting, and reports which ValueTrack params are still missing. Read-only — nothing is changed. If the live read is impossible (no account selected, developer token pending, Google refused access) this still answers 200 with read_error set and the params to paste by hand.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The status.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
connected | boolean | yes | |
customer_id | string \ | null | yes |
final_url_suffix | string \ | null | yes |
auto_tagging_enabled | boolean | yes | Auto-tagging supplies the gclid. Without it neither capture path works. |
complete | boolean | yes | True when every ValueTrack param FunnelKeeper needs is already present. |
missing | string[] | yes | The params still to add. |
proposed_suffix | string | yes | The account’s existing suffix with our params appended — exactly what a deploy would write. |
recommended_suffix | string | yes | Just FunnelKeeper’s params, for pasting by hand. |
deployed_at | string \ | null | yes |
read_error | string \ | null | yes |
404 — No Google Ads connection.
POST /products/{slug}/connections/google-ads/url-suffix
Add FunnelKeeper’s tracking parameters to the Google Ads account. Appends the ValueTrack params to the account-level final URL suffix, keeping any the tenant already had. This changes the landing URL of every ad in the account, so it runs ONLY on an explicit call — never from a job — and is audited. Paste recommended_suffix by hand instead if you would rather not grant the write.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Applied.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
suffix | string | yes | |
added | string[] | yes | |
already_complete | boolean | yes | True when nothing had to be written. |
404 — No Google Ads connection.
409 — Connected but not usable yet — no customer id selected, developer token pending, or Google refused access to the account.
502 — Google Ads failed the write.
pages
GET /products/{slug}/recordings
Session recordings from Clarity and PostHog. Stored recording pointers from the daily sync — playback URLs into whichever tool recorded them, never video. Filter by landing-page path, friction signal, or provider. Empty when no replay source is connected or the sync has not run.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
path | query | string | no |
signal | query | rage_click · dead_click · js_error | no |
provider | query | clarity · posthog | no |
limit | query | integer | no |
200 — The list.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
window_days | integer | yes | |
recordings_url | string \ | null | yes |
sources | object[] | yes | |
recordings | object[] | yes |
GET /products/{slug}/landing-pages
Landing pages with conversion characteristics and latest AI score. Auto-discovered from GA4 or PostHog (top paths by sessions) and from session recordings, plus any URLs added by hand. Traffic is the window vs the previous window of the same length. Unanalysed pages have a null latest_analysis. friction is set when a replay source (Clarity or PostHog) has recordings or page insights for that path; its counts sum across connected providers. Includes saved exclude filters and flags matching pages with is_excluded.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The list.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
window_days | integer | yes | |
pages | object[] | yes | |
filters | object[] | no |
POST /products/{slug}/landing-pages
Track a landing page by URL.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
path | string | yes | |
url | string | yes | |
source | manual · ga4 · clarity · posthog | yes | |
created_at | string | yes | |
traffic | object | yes | |
latest_analysis | object \ | null | yes |
friction | object \ | null | yes |
is_excluded | boolean | no |
409 — That path is already tracked.
GET /products/{slug}/landing-pages/filters
List saved landing-page exclude filters. Returns all saved exclude filters for this project.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The filters.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
filters | object[] | yes |
POST /products/{slug}/landing-pages/filters
Create or update a landing-page exclude filter. Saves a path pattern to exclude from the Pages surface. Patterns like ‘/deals’ match all subpaths like ‘/deals/123’. Wildcards like ‘/guides/*’ are supported.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
pattern | string | yes | Path prefix or glob pattern to exclude, e.g. ‘/deals’, ‘/deals/’, ‘/guides/’ |
enabled | boolean | no | Whether this exclude filter is active. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_id | string | no | |
pattern | string | yes | |
enabled | boolean | yes | |
created_at | string | yes | |
updated_at | string | yes |
POST /products/{slug}/landing-pages/filters/{id}
Update or toggle a landing-page exclude filter. Toggles enabled state or updates pattern for a saved exclude filter.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
pattern | string | no | Updated path prefix or glob pattern. |
enabled | boolean | no | Whether this exclude filter is active. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
id | path | string | yes |
200 — Updated.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_id | string | no | |
pattern | string | yes | |
enabled | boolean | yes | |
created_at | string | yes | |
updated_at | string | yes |
404 — No such filter.
DELETE /products/{slug}/landing-pages/filters/{id}
Delete a landing-page exclude filter. Deletes the saved exclude filter.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
id | path | string | yes |
200 — Deleted.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
404 — No such filter.
GET /products/{slug}/landing-pages/{id}
One landing page: analysis history and daily traffic.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
id | path | string | yes |
days | query | integer | no |
200 — The page.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
path | string | yes | |
url | string | yes | |
source | manual · ga4 · clarity · posthog | yes | |
created_at | string | yes | |
traffic_series | object[] | yes | |
analyses | object[] | yes | |
recordings | object[] | yes | |
recordings_url | string \ | null | yes |
friction | object \ | null | yes |
DELETE /products/{slug}/landing-pages/{id}
Stop tracking a landing page. CONFIG delete — analyses cascade. Traffic rows stay (path-keyed) so a re-add keeps history. GA4 may re-discover a still-popular path on the next sync.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
id | path | string | yes |
200 — Removed.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
POST /products/{slug}/landing-pages/{id}/analyze
Score a landing page against the weighted quality rubric. Fetches the live HTML, sends the text plus rubric to OpenRouter, and appends a new analysis row. On-demand only — never a cron. Requires OPENROUTER_API_KEY.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
id | path | string | yes |
200 — The new analysis (status may be error).
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
analyzed_at | string | yes | |
model | string | yes | |
overall_score | integer \ | null | yes |
scores | object[] | yes | |
summary | string \ | null | yes |
status | ok · error | yes | |
error | string \ | null | yes |
503 — OpenRouter is not configured.
agent
GET /products/{slug}/spend
Daily spend by channel and campaign. Includes the project’s daily cap, whether it is currently binding, and the portfolio ceiling.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Spend rows + caps.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
rows | object[] | yes | |
caps | object | yes |
POST /products/{slug}/proposals
Propose a spend change (budget.propose). The ONLY write that can lead to an ad-network change, and it never executes directly: it queues a card for a HUMAN to approve. Policy caps are enforced here, at the boundary — an over-cap proposal is rejected with policy_code rather than parked. Proposals expire after 72 hours.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
network | meta · google | yes | |
campaign_id | string | no | |
from_cents | integer | no | |
to_cents | integer | yes | Proposed daily cap, integer cents. |
rationale | string | yes | Why. Shown to the human on the card. |
rollback_if | object | no | Condition under which the change should be reverted, e.g. {“cac_usd_above”: 80, “window_days”: 5}. |
headline | string | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
202 — Queued for a human.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
proposal_id | string | yes | |
status | pending_human | yes | |
expires_in_hours | integer | yes |
422 — Refused by the policy engine.
POST /distribution
Log a distribution event (events.log). Record outreach an agent or human performed — a post shipped, a listing submitted, an email sent — so its traffic can be joined back via utm_campaign.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
productSlug | string | yes | |
channel | string | yes | |
kind | string | yes | |
url | string | no | |
utmCampaign | string | no | |
note | string | no |
200 — Logged.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
id | string | yes |
dashboards
GET /products/{slug}/dashboards
List a project’s dashboards. User- and agent-built dashboards. The built-in Overview is not stored — clients render it from a constant spec.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Dashboards, oldest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string | yes | |
name | string | yes | |
description | string \ | null | yes |
spec | object | yes | |
created_by | string | yes | |
updated_by | string \ | null | yes |
created_at | string | yes | |
updated_at | string | yes |
POST /products/{slug}/dashboards
Create a dashboard. Pass a full widget spec to build it exactly (the agent path), a prompt to have the Keeper compose one, or neither to start blank. Audited.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
description | string | no | |
spec | object | no | |
prompt | string | no | Describe the dashboard in plain words; the Keeper composes a spec from it. Ignored when spec is given. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string | yes | |
name | string | yes | |
description | string \ | null | yes |
spec | object | yes | |
created_by | string | yes | |
updated_by | string \ | null | yes |
created_at | string | yes | |
updated_at | string | yes |
400 — Spec failed validation.
POST /products/{slug}/dashboards/generate
Draft a dashboard from a description. Deterministic composer: maps the words in your description onto known widgets. Returns a draft spec — nothing is saved until you POST it to /dashboards.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
prompt | string | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The draft.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
spec | object | yes | |
matched | string[] | yes | What the composer recognised in the prompt — shown so the draft is honest about what it understood. |
POST /dashboards/{id}
Update a dashboard.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | |
description | string \ | null | no |
spec | object | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Updated.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string | yes | |
name | string | yes | |
description | string \ | null | yes |
spec | object | yes | |
created_by | string | yes | |
updated_by | string \ | null | yes |
created_at | string | yes | |
updated_at | string | yes |
404 — Not yours or doesn’t exist.
POST /dashboards/{id}/delete
Delete a dashboard. Dashboards are configuration, not facts — deletion is real and audited.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Deleted.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
attribution
GET /products/{slug}/attribution
Revenue by channel under first- and last-touch models. Spend, first-touch and last-touch revenue, customers, CAC and ROAS per channel, plus attribution coverage. Unattributed revenue is its own row, shown honestly.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
window_days | integer | yes | |
channels | object[] | yes | Includes ‘unattributed’ as its own row — never smeared across channels. |
totals | object | yes | |
primary_goal | object \ | null | no |
coverage | object | yes | How complete the attribution inputs are — the honesty panel. |
GET /products/{slug}/attribution/sales
Every sale in the window, with first- and last-touch channels. One row per transaction (charges, invoices, refunds, adjustments). Unattributed sales are labelled ‘unattributed’ — never smeared. Newest first.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
channel | query | string | no |
limit | query | integer | no |
200 — The sales list.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
window_days | integer | yes | |
total | integer | yes | Sales in the window matching the filter, before LIMIT. |
sales | object[] | yes |
GET /products/{slug}/attribution/ads
ROI by campaign, ad group, keyword or creative. Spend, first- and last-touch revenue, customers, CAC and ROAS below the channel line. Cost comes from the platform at the requested grain; revenue comes from the ad ids captured on the identity’s first click (ValueTrack final URL suffix, or the gclid → click_view sweep). Revenue with no paid click is ‘unattributed’; revenue from an ad with nothing at this grain is ‘no_dimension’ — neither is smeared across the named rows.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
level | query | campaign · ad_group · keyword · ad | no |
limit | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
window_days | integer | yes | |
level | campaign · ad_group · keyword · ad | yes | |
rows | object[] | yes | Includes two honest sentinels: ‘unattributed’ (revenue with no paid click at all) and ‘no_dimension’ (revenue from an ad that has nothing at this grain — Performance Max and Shopping have no keywords). |
totals | object | yes | |
coverage | object | yes | How much of paid revenue can be placed at this grain — the honesty panel. |
400 — Unknown level.
GET /products/{slug}/timeseries
Daily spend, revenue, visits, leads, customers. One row per day, zero-filled — the series behind dashboard trend widgets.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Rows, oldest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
day | string | yes | YYYY-MM-DD |
spend_cents | integer | yes | |
revenue_cents | integer | yes | |
visits | integer | yes | |
leads | integer | yes | |
customers | integer | yes | |
search_clicks | integer | yes | |
search_impressions | integer | yes | |
ai_visibility | number \ | null | yes |
social_impressions | integer | yes | |
social_engagement | integer | yes | |
social_followers | integer | yes |
funnel
GET /products/{slug}/funnel/definition
The project’s funnel definition + tracking status per stage.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Steps and stage availability.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
steps | object[] | yes | |
is_default | boolean | yes | True while the project is on the built-in ladder (nothing saved yet). |
available | object[] | yes | Every canonical stage with its recent tracking status — what’s wired vs what would render empty. |
POST /products/{slug}/funnel/definition
Save the funnel definition. Ordered, labelled steps drawn from the canonical stage taxonomy. Audited; the funnel page and health checks use it immediately.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
steps | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
steps | object[] | yes |
400 — Invalid steps.
GET /products/{slug}/funnel/series
Daily volume per funnel stage. Powers ‘the funnel today vs over time’: clients sum windows for side-by-side comparison and draw per-step trends from the same rows.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Rows, oldest first. Days with no events are absent.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
day | string | yes | |
stage | impression · visit · engaged · lead · qualified · signup · activated · converted · payment · churned | yes | |
volume | integer | yes |
conversions
GET /products/{slug}/conversions
The project’s conversion definitions + 30-day tracking. Primary and secondary conversions over the canonical stage taxonomy, each with its recent volume and value so the editor shows wired vs empty.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Definitions.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
conversions | object[] | yes |
POST /products/{slug}/conversions
Create or update a conversion definition. Upserts by key. tier ‘primary’ is the money event (‘car sold’); ‘secondary’ are milestones (‘car listed’, ‘offer accepted’). value_mode ‘transaction’ reads each conversion’s own amount from the source’s revenue row — never a static number. Audited.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | Stable slug, e.g. ‘car-sold’ or ‘waitlist-signup’. Upserts replace the definition with this key. |
label | string | yes | |
tier | primary · secondary | yes | |
stage | impression · visit · engaged · lead · qualified · signup · activated · converted · payment · churned | yes | The canonical stage whose events count as this conversion. |
source | mysql · postgres · mongo · ga4 · stripe | no | Restrict to one source (‘mysql’ | ‘postgres’ | ‘mongo’ | ‘ga4’ | ‘stripe’); omit to count the stage from any source. |
event_name | string \ | null | no |
value_mode | none · fixed · transaction | no | |
fixed_value_cents | integer \ | null | no |
currency | string \ | null | no |
score_mode | none · grade · numeric | no | ‘none’ (default), ‘grade’ (A/B/C/D lead qualification), or ‘numeric’ (point scoring). |
target_cpa_cents | integer \ | null | no |
grade_weights | object \ | null | no |
is_active | boolean | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
conversion | object | yes |
400 — Invalid definition.
POST /products/{slug}/conversions/{key}/delete
Delete a conversion definition. Definitions are configuration, not facts — deletion is real and audited. The events themselves are untouched.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
key | path | string | yes |
200 — Deleted.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
404 — No such conversion.
GET /products/{slug}/conversions/report
Conversion counts and values, current vs previous window, by channel. Per definition: volume and value in the window vs the prior window, plus a per-channel split (event channel, else identity first touch, else ‘unattributed’).
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
window_days | integer | yes | |
conversions | object[] | yes |
demand
GET /products/{slug}/demand
Demand-gen report: search queries, ranks, AI visibility, authority. Composite read of search_queries, keyword_ranks, seo_snapshots and funnel events. Empty sources still return so the Demand page can render connect CTAs. No second warehouse path — same tables the adapters write.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
window_days | integer | yes | |
funnel | object | yes | |
trend | object[] | yes | |
queries | object[] | yes | |
ranks | object[] | yes | |
ai_visibility | object | yes | |
authority | object | yes | |
sources | object[] | yes |
GET /products/{slug}/social
Organic social report: audience, reach, engagement, top posts. Composite read of social_snapshots and social_posts. Empty sources still return so Demand can render connect CTAs.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
window_days | integer | yes | |
audience | object | yes | |
networks | object[] | yes | |
trend | object[] | yes | |
posts | object[] | yes |
health
GET /products/{slug}/health-report
Audit the project: site & tracking, funnel, advertising, SEO & social. Deterministic checks over the warehouse — a scored, actionable audit in the spirit of an SEO site health report. Every non-pass check carries the action that fixes it.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
generated_at | string | yes | |
window_days | integer | yes | |
score | integer \ | null | yes |
grade | string \ | null | yes |
categories | object[] | yes |
GET /health
Pipeline health. Job runs (with consecutive failures), per-source data freshness, queue backlog, and the share of revenue that cannot be attributed — shown honestly, never smeared across channels.
200 — Health report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
jobs | object[] | yes | |
sources | object[] | yes | |
queue | object \ | null | yes |
attribution | object[] | yes | |
products | object[] | yes | |
now | string | yes |
growth
GET /products/{slug}/growth-actions
Next incremental growth actions for this project. Deterministic ranking of what to build or fix next so the project generates more revenue: activation leaks, friction, wasted spend, missing conversions, weak landing pages. Each action includes a copy-paste prompt for Cursor, Claude Code, Lovable, Bolt, or v0. Spend changes are never executed here — those stay proposals a human approves.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
tool | query | cursor · claude_code · lovable · bolt · v0 | no |
200 — Ranked actions.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
product_name | string | yes | |
generated_at | string | yes | |
window_days | integer | yes | |
actions | object[] | yes |
POST /products/{slug}/growth-actions/{id}/prompt
Render a growth action as a prompt for a specific coding agent. Recomputes the action and returns a prompt tailored to cursor, claude_code, lovable, bolt, or v0. 404 if that id is no longer in the current ranking.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
tool | cursor · claude_code · lovable · bolt · v0 | no | |
days | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
id | path | string | yes |
200 — The prompt.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
tool | cursor · claude_code · lovable · bolt · v0 | yes | |
prompt | string | yes | |
headline | string | yes | |
revenue_impact_cents | integer \ | null | yes |
currency | string | yes |
404 — Unknown project or action id.
tracking
POST /products/{slug}/events
Ingest first-party events (server-side). Authenticated by the account API key. Each event is a canonical funnel stage plus optional name, identity, attribution, and value. dedupe_id is required — retries are append-only (ON CONFLICT DO NOTHING). Value is integer cents. Source namespace is api:<slug>.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
events | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Accepted (duplicates silently dropped).
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
received | integer | yes |
400 — Validation failed.
POST /t/events
Public — no authentication.
Ingest first-party events (browser snippet). Public. Authenticated by the fk_pub_… write key in the body (the token is the credential). Batch cap 25. Source namespace is web:<slug>. Forged events with a leaked key only pollute that project’s own data.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | The project’s publishable write key. |
events | object[] | yes |
200 — Accepted.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
received | integer | yes |
401 — Missing or revoked write key.
GET /fk.js
Public — no authentication.
Browser tracking snippet. Vanilla JS. Reads data-key from its own script tag, captures landing attribution, exposes fk.track / fk.identify. Cacheable.
200 — JavaScript.
GET /products/{slug}/tracking-key
The project’s publishable write key and install snippets.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Key or null if none minted yet.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
key | string \ | null | yes |
created_at | string \ | null | yes |
snippet | string \ | null | yes |
gtm_html | string \ | null | yes |
curl_example | string | yes | |
ingest_url | string | yes | |
gtm_connected | boolean | yes | |
gtm_deploy | object \ | null | yes |
POST /products/{slug}/tracking-key
Create or rotate the publishable write key. Mints a new fk_pub_… key and revokes the previous one. Audited. The old key stops ingesting immediately — update the snippet.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The new key.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
key | string | yes | The new fk_pub_… key. Publishable — it ships in page source. |
created_at | string | yes | |
rotated | boolean | yes | |
snippet | string | yes |
POST /products/{slug}/tracking/gtm-deploy
Publish the tracking snippet via Google Tag Manager. Creates a GTM workspace, adds a Custom HTML tag (All Pages) with the snippet, creates a container version, and publishes it. Requires an active GTM connection with edit+publish scopes. A 403 with reconnect=true means the grant is still read-only — re-run the Google connect flow. Audited. This publishes to the live container.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Published.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
workspace_name | string | yes | |
version_name | string | yes | |
published | boolean | yes |
403 — Reconnect Google to enable deploy.
409 — No write key or GTM not connected.
products
POST /products/{slug}/onboarding-complete
Mark first-run onboarding finished for a project. Called by the dashboard wizard when someone reaches the final step. Stores no state — it writes an onboarding_complete audit row and fires the operator notification. Idempotent: replays return first_time: false and notify nobody.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Recorded.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
first_time | boolean | yes |
history
GET /history
Every change and decision, human or AI. The audit log as a timeline: Keeper cards raised, human approvals/dismissals (resolved_by), policy blocks, proposals, config edits. Project-scoped events only; account-plumbing events (logins) are not included for tenants.
| Parameter | In | Type | Required |
|---|---|---|---|
product | query | string | no |
days | query | integer | no |
limit | query | integer | no |
200 — Events, newest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | integer | yes | |
occurred_at | string | yes | |
actor | string | yes | A human’s email, or ‘keeper’ / ‘system’ / ‘policy’. |
via | string | yes | dashboard | mcp | api | job | chat |
action | string | yes | |
product_slug | string \ | null | yes |
subject_ref | string \ | null | yes |
card_headline | string \ | null | yes |
card_type | string \ | null | yes |
payload | object | yes |
billing
GET /billing
Current plan, trial, and usage. Effective plan (Growth during the 30-day no-card trial), usage vs limits, and whether Stripe checkout is available. The workspace holds one plan; quantity is how many projects it pays for — each project is free for its own 30 days, then joins the bill.
200 — Plan and usage.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
plan | free · indie · growth | yes | |
stored_plan | free · indie · growth | yes | |
plan_status | active · trialing · past_due · canceled | yes | |
trial_ends_at | string \ | null | yes |
trial_days_left | integer \ | null | yes |
plan_period_end | string \ | null | yes |
billing_available | boolean | yes | |
has_customer | boolean | yes | |
subscribed | boolean | yes | |
member_role | owner · member | yes | |
period | string | yes | |
usage | object | yes | |
limits | object | yes | |
billable_products | integer | yes | |
quantity | integer | yes | |
product_lines | object[] | yes | |
features | object | yes | |
plans | object[] | yes |
POST /billing/checkout
Start Stripe Checkout for Indie or Growth.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
plan | indie · growth | yes |
200 — Checkout URL.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes |
503 — Stripe is not configured.
POST /billing/portal
Open the Stripe customer portal. Payment method, invoices, cancel, and plan changes live in Stripe’s portal.
200 — Portal URL.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes |
POST /billing/webhook
Public — no authentication.
Stripe webhook (public; signature-verified).
200 — Received.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
GET /products/{slug}/export/events.csv
CSV export of events (Growth).
200 — text/csv
GET /products/{slug}/export/transactions.csv
CSV export of transactions (Growth).
200 — text/csv
scorecard
GET /products/{slug}/scorecard
Weekly growth scorecard (metrics × weeks). ISO-week matrix of auto-filled warehouse metrics plus any manual cells. First read provisions a default layout from connected sources. History window follows the plan. Weeks are completed ISO weeks; pass current=1 to append the in-progress week.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
weeks | query | integer | no |
current | query | 1 | no |
200 — The matrix.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
product_name | string | yes | |
currency | string | yes | |
weeks | object[] | yes | |
sections | object[] | yes | |
template | string \ | null | yes |
spreadsheet_id | string \ | null | yes |
share_url | string \ | null | yes |
sheets_enabled | boolean | yes | Whether this server can push to Google Sheets. |
catalog | object[] | yes | |
clamped | boolean | yes |
POST /products/{slug}/scorecard
Save the scorecard definition.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
sections | object[] | yes | |
template | string \ | null | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Updated matrix.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
product_name | string | yes | |
currency | string | yes | |
weeks | object[] | yes | |
sections | object[] | yes | |
template | string \ | null | yes |
spreadsheet_id | string \ | null | yes |
share_url | string \ | null | yes |
sheets_enabled | boolean | yes | Whether this server can push to Google Sheets. |
catalog | object[] | yes | |
clamped | boolean | yes |
GET /products/{slug}/scorecard/catalog
Scorecard metric catalog and templates.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Catalog.
GET /products/{slug}/scorecard/insights
Scorecard analysis: cell highlights + insight/query/action cards. Deterministic week-on-week anomaly pass on every plan; the LLM enriches the highlights and cards on Growth (cached). Never proposes spend changes — those stay in the approval queue.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
weeks | query | integer | no |
200 — The analysis.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
highlights | object[] | yes | |
cards | object[] | yes | |
ai | boolean | yes | True when the LLM enriched the analysis (Growth). |
model | string \ | null | yes |
generated_at | string | yes |
POST /products/{slug}/scorecard/template
Replace the scorecard with a named template.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
template | default · saas_waitlist · paid_acquisition · content_led | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Updated matrix.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
product_name | string | yes | |
currency | string | yes | |
weeks | object[] | yes | |
sections | object[] | yes | |
template | string \ | null | yes |
spreadsheet_id | string \ | null | yes |
share_url | string \ | null | yes |
sheets_enabled | boolean | yes | Whether this server can push to Google Sheets. |
catalog | object[] | yes | |
clamped | boolean | yes |
POST /products/{slug}/scorecard/values
Write manual scorecard cells.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
cells | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
POST /products/{slug}/scorecard/suggest
LLM metric suggestions (Indie+). Proposals only — never auto-applied. Grounded against the catalog.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
prompt | string | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Suggested metrics.
GET /products/{slug}/scorecard/export.csv
CSV export of the scorecard (Growth).
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
weeks | query | integer | no |
current | query | 1 | no |
200 — text/csv
GET /products/{slug}/scorecard/export.xlsx
Excel export of the scorecard (Growth).
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
weeks | query | integer | no |
current | query | 1 | no |
200 — application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
POST /products/{slug}/scorecard/share
Mint a public read-only share link.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Share URL.
POST /products/{slug}/scorecard/share/revoke
Revoke the public share link.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Revoked.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
POST /products/{slug}/scorecard/sheets/sync
Push the scorecard to Google Sheets (Growth, env-gated).
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Spreadsheet id and URL.
GET /t/scorecard/{token}
Public — no authentication.
Public read-only scorecard (share token).
| Parameter | In | Type | Required |
|---|---|---|---|
token | path | string | yes |
200 — The matrix.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
product_name | string | yes | |
currency | string | yes | |
weeks | object[] | yes | |
sections | object[] | yes | |
template | string \ | null | yes |
spreadsheet_id | string \ | null | yes |
share_url | string \ | null | yes |
sheets_enabled | boolean | yes | Whether this server can push to Google Sheets. |
catalog | object[] | yes | |
clamped | boolean | yes |
chat
POST /chat
Ask the Keeper (workspace-scoped). Conversational surface for setup, dashboard/funnel/conversion edits, metric questions, and copy-paste growth prompts. The Keeper calls the same account-scoped reads and writes as the rest of the API. Spend changes become proposal cards — nothing executes without a human resolved_by. Audited as via=chat.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
messages | object[] | yes | |
stream | boolean | no | When true, the server replies as text/event-stream (tool / delta / done / error events). |
section | string | no | The dashboard section the human is looking at (dashboard, funnel, …). Shapes the Keeper’s first look. |
200 — The Keeper’s reply (or an SSE stream when stream=true).
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
reply | string | yes | |
model | string | yes | |
tool_calls | object[] | yes | |
suggested_actions | object[] | yes |
503 — OPENROUTER_API_KEY is not set.
POST /products/{slug}/chat
Ask the Keeper about one project. Same as POST /chat, with this project as the default scope so the model does not have to guess the slug.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
messages | object[] | yes | |
stream | boolean | no | When true, the server replies as text/event-stream (tool / delta / done / error events). |
section | string | no | The dashboard section the human is looking at (dashboard, funnel, …). Shapes the Keeper’s first look. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The Keeper’s reply (or an SSE stream when stream=true).
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
reply | string | yes | |
model | string | yes | |
tool_calls | object[] | yes | |
suggested_actions | object[] | yes |
503 — OPENROUTER_API_KEY is not set.