Browse documentation

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:

FieldTypeRequiredNotes
emailstringyes
passwordstringyesAt least 10 characters.
anonIdstringnoOpinly pixel anonymous id (window.opinly.anonId). Joins the server-side sign_up to the browser visit. Ignored if missing or malformed.
metaobjectno

200 — Verification email sent (or already registered).

Response:

FieldTypeRequiredNotes
oktrueyes
statusverification_sentyes

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:

FieldTypeRequiredNotes
emailstringyes
prompt_typegrowth-engine · payback · mcpnoWhich prompt and guide to send to the recipient.
metaobjectno

200 — Prompt sent to inbox

Response:

FieldTypeRequiredNotes
oktrueyes
messagestringyes

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:

FieldTypeRequiredNotes
emailstringyes
namestringno

200 — Account created; API key shown once.

Response:

FieldTypeRequiredNotes
account_idstringyes
api_keystringyesThe full API key (fk_live_…). Shown exactly once — store it now.
claim_urlstringyesSingle-use link the human opens to set a password and take over the account.
claim_expires_atstringyes

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:

FieldTypeRequiredNotes
claim_urlstringyes
expires_atstringyes

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.

ParameterInTypeRequired
tokenquerystringyes

302 — Verified; redirecting to the dashboard.

400 — Invalid or expired token.

POST /auth/login

Public — no authentication.

Log in.

Request body:

FieldTypeRequiredNotes
emailstringyes
passwordstringno

200 — Session created.

Response:

FieldTypeRequiredNotes
tokenstringyesSession bearer token (fk_sess_…). Expires after 7 idle days.
expires_atstringyes
accountobjectyes
accountsobject[]noThe person’s accepted memberships (for the account switcher).
is_newbooleannoTrue 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/. Authenticated by the signed state, not a bearer.

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:

FieldTypeRequiredNotes
tokenstringyesThe one-time ticket from the #/auth/google/ redirect.

200 — Session created.

Response:

FieldTypeRequiredNotes
tokenstringyesSession bearer token (fk_sess_…). Expires after 7 idle days.
expires_atstringyes
accountobjectyes
accountsobject[]noThe person’s accepted memberships (for the account switcher).
is_newbooleannoTrue 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:

FieldTypeRequiredNotes
oktrueyes

GET /auth/me

Who am I.

200 — The authenticated account.

Response:

FieldTypeRequiredNotes
idstringyesThe ACCOUNT (tenant) id — projects and API keys hang off this.
emailstringyesThe authenticated person’s email.
rolecustomer · operatoryesThe tenant’s role.
member_roleowner · membernoThe person’s role within the account.
namestring \nullno
account_namestringnoThe tenant’s display name (the switcher label).
verifiedbooleanyes
created_atstringyes
platform_adminbooleannoTrue 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:

FieldTypeRequiredNotes
namestring \nullno
current_passwordstringnoRequired when new_password is set.
new_passwordstringno

200 — Updated; the new account payload (same shape as /auth/login).

Response:

FieldTypeRequiredNotes
idstringyesThe ACCOUNT (tenant) id — projects and API keys hang off this.
emailstringyesThe authenticated person’s email.
rolecustomer · operatoryesThe tenant’s role.
member_roleowner · membernoThe person’s role within the account.
namestring \nullno
account_namestringnoThe tenant’s display name (the switcher label).
verifiedbooleanyes
created_atstringyes
platform_adminbooleannoTrue 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):

FieldTypeRequiredNotes
idstringyes
namestringyes
rolecustomer · operatoryes
member_roleowner · memberyes

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:

FieldTypeRequiredNotes
namestringyes

200 — Created and switched.

Response:

FieldTypeRequiredNotes
accountobjectyes
accountsobject[]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:

FieldTypeRequiredNotes
account_idstringyes

200 — Switched.

Response:

FieldTypeRequiredNotes
accountobjectyes
accountsobject[]yes

400 — Not a session.

404 — No such membership.

GET /auth/keys

List API keys.

200 — Keys (prefixes only).

Response (array of):

FieldTypeRequiredNotes
idstringyes
namestringyes
prefixstringyes
created_atstringyes
last_used_atstring \nullyes

POST /auth/keys

Create an API key. The response contains the full key exactly once.

Request body:

FieldTypeRequiredNotes
namestringno

200 — Created.

Response:

FieldTypeRequiredNotes
idstringyes
namestringyes
keystringyesThe full API key (fk_live_…). Shown exactly once — store it now.

POST /auth/keys/{id}/revoke

Revoke an API key.

ParameterInTypeRequired
idpathstringyes

200 — Revoked.

Response:

FieldTypeRequiredNotes
oktrueyes

team

GET /team

List the account’s team.

200 — Members, oldest first.

Response (array of):

FieldTypeRequiredNotes
idstringyes
emailstringyes
namestring \nullyes
member_roleowner · memberyes
statusactive · invited · disabledyes
created_atstringyes
last_seen_atstring \nullyes

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:

FieldTypeRequiredNotes
emailstringyes
member_roleowner · memberno

200 — Invited.

Response:

FieldTypeRequiredNotes
oktrueyes
user_idstringyes
invite_urlstringyes
expires_in_hoursintegeryes
already_registeredbooleanyes

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.

ParameterInTypeRequired
idpathstringyes

200 — Done.

Response:

FieldTypeRequiredNotes
oktrueyes

400 — Guard rail refused it.

403 — Not an owner.

POST /team/{id}/enable

Re-enable a team member. Owners only.

ParameterInTypeRequired
idpathstringyes

200 — Done.

Response:

FieldTypeRequiredNotes
oktrueyes

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:

FieldTypeRequiredNotes
member_roleowner · memberyes
ParameterInTypeRequired
idpathstringyes

200 — Changed.

Response:

FieldTypeRequiredNotes
oktrueyes

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.

ParameterInTypeRequired
tokenquerystringyes

200 — Invite is valid.

Response:

FieldTypeRequiredNotes
emailstringyes
account_namestringyes
needs_passwordbooleanyes

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:

FieldTypeRequiredNotes
tokenstringyes
passwordstringnoRequired when the invitee has no password yet. Existing logins omit it.
namestringno

200 — Joined; session created.

Response:

FieldTypeRequiredNotes
tokenstringyesSession bearer token (fk_sess_…). Expires after 7 idle days.
expires_atstringyes
accountobjectyes
accountsobject[]noThe person’s accepted memberships (for the account switcher).
is_newbooleannoTrue 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):

FieldTypeRequiredNotes
product_idstringyes
slugstringyes
namestringyes
stagevalidation · live · killedyes
currencystringyes
domainstring \nullno
gate_resultpass · fail · pivotyes
gate_due_datestring \nullyes
spend_7d_centsintegeryes
spend_30d_centsintegeryes
spend_prev_30d_centsintegeryes
visits_30dintegeryes
leads_30dintegeryes
revenue_30d_centsintegeryes
revenue_prev_30d_centsintegeryes
customers_30dintegeryes
cac_centsinteger \nullyes
ltv_centsinteger \nullyes
ltv_cacnumber \nullyes
pending_cardsintegeryes

POST /products

Create a project.

Request body:

FieldTypeRequiredNotes
namestringyes
slugstringyes
domainstringno
currencystringno

200 — Created.

Response:

FieldTypeRequiredNotes
idstringyes
slugstringyes
namestringyes
currencystringyes
stagestringyes

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:

FieldTypeRequiredNotes
namestringno
currencystringno
domainstring \nullno
ParameterInTypeRequired
slugpathstringyes

200 — Updated.

Response:

FieldTypeRequiredNotes
idstringyes
slugstringyes
namestringyes
currencystringyes
domainstring \nullyes

400 — Nothing to update.

404 — Not yours or doesn’t exist.

GET /products/{slug}

One project’s portfolio row.

ParameterInTypeRequired
slugpathstringyes

200 — The row.

Response:

FieldTypeRequiredNotes
product_idstringyes
slugstringyes
namestringyes
stagevalidation · live · killedyes
currencystringyes
domainstring \nullno
gate_resultpass · fail · pivotyes
gate_due_datestring \nullyes
spend_7d_centsintegeryes
spend_30d_centsintegeryes
spend_prev_30d_centsintegeryes
visits_30dintegeryes
leads_30dintegeryes
revenue_30d_centsintegeryes
revenue_prev_30d_centsintegeryes
customers_30dintegeryes
cac_centsinteger \nullyes
ltv_centsinteger \nullyes
ltv_cacnumber \nullyes
pending_cardsintegeryes

404 — Not yours or doesn’t exist.

GET /products/{slug}/funnel

Funnel stage × channel volumes.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — Rows.

Response (array of):

FieldTypeRequiredNotes
stagestringyes
channelstring \nullyes
eventsintegeryes
volumeintegeryes

GET /products/{slug}/payback

Cohort payback curves + per-channel CAC.

ParameterInTypeRequired
slugpathstringyes

200 — cohorts: cumulative revenue per cohort-day; cac: 90-day spend / first-touch customers per channel.

Response:

FieldTypeRequiredNotes
cohortsobject[]yes
cacobject[]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):

FieldTypeRequiredNotes
idstringyes
product_slugstring \nullyes
card_typeproposal · insight · alert · gate_result · policy_blockyes
headlinestringyes
metric_linestringyes
detailstring \nullyes
proposed_bykeeper · systemyes
action_specobject \nullyes
statusstringyes
created_atstringyes
expires_atstring \nullyes

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:

FieldTypeRequiredNotes
actorstringnoOperator-only override; everyone else IS the actor (their authenticated email).
snoozeHoursintegerno
ParameterInTypeRequired
idpathstringyes

200 — Done.

Response:

FieldTypeRequiredNotes
oktrueyes

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:

FieldTypeRequiredNotes
actorstringnoOperator-only override; everyone else IS the actor (their authenticated email).
snoozeHoursintegerno
ParameterInTypeRequired
idpathstringyes

200 — Done.

Response:

FieldTypeRequiredNotes
oktrueyes

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:

FieldTypeRequiredNotes
actorstringnoOperator-only override; everyone else IS the actor (their authenticated email).
snoozeHoursintegerno
ParameterInTypeRequired
idpathstringyes

200 — Done.

Response:

FieldTypeRequiredNotes
oktrueyes

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:

FieldTypeRequiredNotes
actorstringnoOperator-only override; everyone else IS the actor (their authenticated email).
snoozeHoursintegerno
ParameterInTypeRequired
idpathstringyes

200 — Done.

Response:

FieldTypeRequiredNotes
oktrueyes

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):

FieldTypeRequiredNotes
idstringyes
kindstringyes
statuspending · active · error · disabledyes
configobject \nullyes
last_sync_atstring \nullyes
last_errorstring \nullyes
backfill_daysintegeryesHow 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.
syncingbooleanyesA 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_phasestring \nullyes
product_slugstringyes

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:

FieldTypeRequiredNotes
product_slugstringyes
kindsga4 · gtm · google_ads · gsc[]yes
clientdashboard · cli · mcp · apino

200 — The URL and the state to poll with.

Response:

FieldTypeRequiredNotes
statestringyes
auth_urlstringyes
expires_atstringyes

GET /connect/google/status

Poll an in-progress Google connection.

ParameterInTypeRequired
statequerystringyes

200 — pending | complete (with entity options for the select step) | error.

Response:

FieldTypeRequiredNotes
statuspending · complete · erroryes
product_slugstringyes
errorstringno
resultsobject[]no
optionsobjectno

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:

FieldTypeRequiredNotes
statestringyes
ga4_property_idstringno
gtm_container_pathstringno
ads_customer_idstringno
gsc_site_urlstringno

200 — Activated.

Response:

FieldTypeRequiredNotes
oktrueyes
activatedstring[]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:

FieldTypeRequiredNotes
product_slugstringyes
kindsmeta_ads · meta_social[]no
clientdashboard · cli · mcp · apino

200 — The URL and the state to poll with.

Response:

FieldTypeRequiredNotes
statestringyes
auth_urlstringyes
expires_atstringyes

GET /connect/meta/status

Poll an in-progress Meta connection.

ParameterInTypeRequired
statequerystringyes

200 — pending | complete (with entity options for the select step) | error.

Response:

FieldTypeRequiredNotes
statuspending · complete · erroryes
product_slugstringyes
errorstringno
resultsobject[]no
optionsobjectno

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:

FieldTypeRequiredNotes
statestringyes
ad_account_idstringno
page_idstringno

200 — Activated.

Response:

FieldTypeRequiredNotes
oktrueyes
activatedstring[]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:

FieldTypeRequiredNotes
product_slugstringyes
kindsga4 · gtm · gsc[]yes

200 — The email to grant, plus per-kind instructions.

Response:

FieldTypeRequiredNotes
sa_emailstringyes
kindsga4 · gtm · gsc[]yes
instructionsobjectyes

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:

FieldTypeRequiredNotes
product_slugstringyes
ga4_property_idstringno
gtm_container_pathstringno
gsc_site_urlstringno

200 — Discovered options, or activated kinds.

Response:

FieldTypeRequiredNotes
statuscompleteno
oktrueno
sa_emailstringyes
activatedstring[]no
optionsobjectno

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:

FieldTypeRequiredNotes
api_keystringyes
domainstringyes
databasestringno
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
api_tokenstringyesClarity Data Export JWT. Generated by a project admin under Settings → Data Export. Stored encrypted; never returned.
project_idstringyesThe Clarity project id, used to build playback and recordings-list URLs.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
api_keystringyesOpinly API key from Settings → Developers (sk-…). Stored encrypted; never returned.
company_idstringnoOpinly company id. Optional when the key can see only one company; required when it can see several.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
api_keystringyesBing Webmaster Tools API key from Settings → API Access. Stored encrypted; never returned.
site_urlstringyesThe verified site URL exactly as it appears in Bing Webmaster (https://example.com/).
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
api_keystringyesAhrefs API v3 token. Stored encrypted; never returned.
domainstringyesThe domain to track (example.com), without a scheme.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
access_tokenstringyesMeta user or system-user token with ads_read. Stored encrypted; never returned.
ad_account_idstringnoNumeric ad account id (no act_ prefix). Omit to discover, then reconnect with one.
campaign_name_prefixesstring[]noOnly 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.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes
accountsobject[]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:

FieldTypeRequiredNotes
campaign_name_prefixesstring[]yesOnly 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.
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
campaign_name_prefixesstring[]yesOnly 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:

FieldTypeRequiredNotes
access_tokenstringyesLinkedIn token with r_ads and r_ads_reporting. Stored encrypted; never returned.
ad_account_idstringnoNumeric sponsored account id. Omit to discover.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes
accountsobject[]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:

FieldTypeRequiredNotes
access_tokenstringyesMeta user token with pages_show_list and pages_read_engagement. Stored encrypted.
page_idstringnoFacebook Page id. Omit to discover.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes
pagesobject[]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:

FieldTypeRequiredNotes
bearer_tokenstringyesX API v2 Bearer token. Stored encrypted; never returned.
usernamestringyesX username without @.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
api_keystringyesStripe 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_stageconverted · paymentnoFunnel 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.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
api_keystringyesPostHog personal API key, scoped to query:read (funnel events) and, for replay, session_recording:read. Stored encrypted; never returned.
project_idstringyesThe PostHog project id — the number in your project URL.
hoststringnoPostHog origin. Defaults to https://us.posthog.com; use https://eu.posthog.com for EU cloud, or your own origin when self-hosted.
ParameterInTypeRequired
slugpathstringyes

200 — Stored.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes

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:

FieldTypeRequiredNotes
event_mapsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
event_mapsobject[]yes

404 — No PostHog connection yet — connect it first.

POST /products/{slug}/connections/{kind}/test

Test a connection.

ParameterInTypeRequired
slugpathstringyes
kindpathga4 · gtm · google_ads · gsc · semrush · mysql · postgres · mongo · clarity · posthog · opinly · bing · ahrefs · meta_ads · linkedin_ads · meta_social · x_social · stripeyes

200 — ok + human-readable detail.

Response:

FieldTypeRequiredNotes
okbooleanyes
detailstringyes

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.

ParameterInTypeRequired
slugpathstringyes
kindpathmysql · postgres · mongo · ga4 · posthog · opinly · gsc · bing · meta_ads · linkedin_ads · meta_social · x_social · stripeyes

200 — Started.

Response:

FieldTypeRequiredNotes
oktrueyes
startedbooleanyesFalse when a sync for this connection was already running — that one is left alone.
daysintegeryesLookback 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.
detailstringyes

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.

ParameterInTypeRequired
slugpathstringyes
kindpathstringyes

200 — Disconnected.

Response:

FieldTypeRequiredNotes
oktrueyes
credential_deletedbooleanyesFalse when the credential is shared and another connection still uses it.
revoked_upstreambooleanyesGoogle 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:

FieldTypeRequiredNotes
urlstringnoWebsite URL to scan (e.g. ‘https://motormerchants.com.au’). If omitted on project-scoped endpoint, defaults to project domain or landing page.
ParameterInTypeRequired
slugpathstringyes

200 — Detection results.

Response:

FieldTypeRequiredNotes
urlstringyesThe requested scan URL.
final_urlstringyesThe resolved URL after redirects.
domainstringyesThe website domain.
detected_countintegeryesTotal number of detected tools and integrations.
integrationsobject[]yesList of detected tools and tracking tags.
platformobject \nullyes
summarystringyesOne-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:

FieldTypeRequiredNotes
urlstringyes

200 — Detection results.

Response:

FieldTypeRequiredNotes
urlstringyesThe requested scan URL.
final_urlstringyesThe resolved URL after redirects.
domainstringyesThe website domain.
detected_countintegeryesTotal number of detected tools and integrations.
integrationsobject[]yesList of detected tools and tracking tags.
platformobject \nullyes
summarystringyesOne-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:

FieldTypeRequiredNotes
event_mapsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
event_mapsobject[]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:

FieldTypeRequiredNotes
credentialsobjectnoTenant path: the database credentials, encrypted at rest. Mutually exclusive with the *_env fields.
host_envstringnoThe NAME of an environment variable on the API host — never the value.
port_envstringnoThe NAME of an environment variable on the API host — never the value.
user_envstringnoThe NAME of an environment variable on the API host — never the value.
password_envstringnoThe NAME of an environment variable on the API host — never the value.
database_envstringnoThe NAME of an environment variable on the API host — never the value.
event_mapsobject[]no
statuspending · activeno‘active’ syncs on the next hourly run; ‘pending’ stages the config without syncing.
ParameterInTypeRequired
slugpathstringyes

200 — Configured.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes
statusstringyes

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:

FieldTypeRequiredNotes
event_mapsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
event_mapsobject[]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:

FieldTypeRequiredNotes
sourcepreset · aiyes‘preset’ returns applicable common templates; ‘ai’ asks the model to infer maps from the live schema + 3 sample rows.
preset_idstringnoWhen source=preset, optionally return just this template’s maps as suggestions. Omit to list every applicable preset.
tablesstring[]noOptional subset of tables to describe and send to the model. Omit to use the first 30. Empty array is rejected on the AI path.
ParameterInTypeRequired
slugpathstringyes

200 — Proposed maps.

Response:

FieldTypeRequiredNotes
sourcepreset · aiyes
modelstringnoOpenRouter model id — present on the AI path.
schema_summaryobjectyes
suggestionsobject[]yesProposed maps for the AI path, or for a specific preset_id. Empty when source=preset and preset_id is omitted — use presets then.
presetsobject[]noApplicable 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:

FieldTypeRequiredNotes
credentialsobjectyesStored as one encrypted blob in account_credentials; never in connection config, never readable back.
event_mapsobject[]no
statuspending · activeno‘active’ syncs on the next hourly run; ‘pending’ stages the config without syncing.
ParameterInTypeRequired
slugpathstringyes

200 — Configured.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes
statusstringyes

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:

FieldTypeRequiredNotes
event_mapsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
event_mapsobject[]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:

FieldTypeRequiredNotes
credentialsobjectyesStored as one encrypted blob in account_credentials; never in connection config, never readable back.
event_mapsobject[]no
statuspending · activeno‘active’ syncs on the next hourly run; ‘pending’ stages the config without syncing.
ParameterInTypeRequired
slugpathstringyes

200 — Configured.

Response:

FieldTypeRequiredNotes
oktrueyes
connection_idstringyes
statusstringyes

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:

FieldTypeRequiredNotes
event_mapsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
event_mapsobject[]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.

ParameterInTypeRequired
slugpathstringyes

200 — The status.

Response:

FieldTypeRequiredNotes
connectedbooleanyes
customer_idstring \nullyes
final_url_suffixstring \nullyes
auto_tagging_enabledbooleanyesAuto-tagging supplies the gclid. Without it neither capture path works.
completebooleanyesTrue when every ValueTrack param FunnelKeeper needs is already present.
missingstring[]yesThe params still to add.
proposed_suffixstringyesThe account’s existing suffix with our params appended — exactly what a deploy would write.
recommended_suffixstringyesJust FunnelKeeper’s params, for pasting by hand.
deployed_atstring \nullyes
read_errorstring \nullyes

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.

ParameterInTypeRequired
slugpathstringyes

200 — Applied.

Response:

FieldTypeRequiredNotes
oktrueyes
suffixstringyes
addedstring[]yes
already_completebooleanyesTrue 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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno
pathquerystringno
signalqueryrage_click · dead_click · js_errorno
providerqueryclarity · posthogno
limitqueryintegerno

200 — The list.

Response:

FieldTypeRequiredNotes
window_daysintegeryes
recordings_urlstring \nullyes
sourcesobject[]yes
recordingsobject[]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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — The list.

Response:

FieldTypeRequiredNotes
window_daysintegeryes
pagesobject[]yes
filtersobject[]no

POST /products/{slug}/landing-pages

Track a landing page by URL.

Request body:

FieldTypeRequiredNotes
urlstringyes
ParameterInTypeRequired
slugpathstringyes

200 — Created.

Response:

FieldTypeRequiredNotes
idstringyes
pathstringyes
urlstringyes
sourcemanual · ga4 · clarity · posthogyes
created_atstringyes
trafficobjectyes
latest_analysisobject \nullyes
frictionobject \nullyes
is_excludedbooleanno

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.

ParameterInTypeRequired
slugpathstringyes

200 — The filters.

Response:

FieldTypeRequiredNotes
filtersobject[]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:

FieldTypeRequiredNotes
patternstringyesPath prefix or glob pattern to exclude, e.g. ‘/deals’, ‘/deals/’, ‘/guides/’
enabledbooleannoWhether this exclude filter is active.
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
idstringyes
product_idstringno
patternstringyes
enabledbooleanyes
created_atstringyes
updated_atstringyes

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:

FieldTypeRequiredNotes
patternstringnoUpdated path prefix or glob pattern.
enabledbooleannoWhether this exclude filter is active.
ParameterInTypeRequired
slugpathstringyes
idpathstringyes

200 — Updated.

Response:

FieldTypeRequiredNotes
idstringyes
product_idstringno
patternstringyes
enabledbooleanyes
created_atstringyes
updated_atstringyes

404 — No such filter.

DELETE /products/{slug}/landing-pages/filters/{id}

Delete a landing-page exclude filter. Deletes the saved exclude filter.

ParameterInTypeRequired
slugpathstringyes
idpathstringyes

200 — Deleted.

Response:

FieldTypeRequiredNotes
oktrueyes

404 — No such filter.

GET /products/{slug}/landing-pages/{id}

One landing page: analysis history and daily traffic.

ParameterInTypeRequired
slugpathstringyes
idpathstringyes
daysqueryintegerno

200 — The page.

Response:

FieldTypeRequiredNotes
idstringyes
pathstringyes
urlstringyes
sourcemanual · ga4 · clarity · posthogyes
created_atstringyes
traffic_seriesobject[]yes
analysesobject[]yes
recordingsobject[]yes
recordings_urlstring \nullyes
frictionobject \nullyes

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.

ParameterInTypeRequired
slugpathstringyes
idpathstringyes

200 — Removed.

Response:

FieldTypeRequiredNotes
oktrueyes

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.

ParameterInTypeRequired
slugpathstringyes
idpathstringyes

200 — The new analysis (status may be error).

Response:

FieldTypeRequiredNotes
idstringyes
analyzed_atstringyes
modelstringyes
overall_scoreinteger \nullyes
scoresobject[]yes
summarystring \nullyes
statusok · erroryes
errorstring \nullyes

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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — Spend rows + caps.

Response:

FieldTypeRequiredNotes
rowsobject[]yes
capsobjectyes

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:

FieldTypeRequiredNotes
networkmeta · googleyes
campaign_idstringno
from_centsintegerno
to_centsintegeryesProposed daily cap, integer cents.
rationalestringyesWhy. Shown to the human on the card.
rollback_ifobjectnoCondition under which the change should be reverted, e.g. {“cac_usd_above”: 80, “window_days”: 5}.
headlinestringno
ParameterInTypeRequired
slugpathstringyes

202 — Queued for a human.

Response:

FieldTypeRequiredNotes
proposal_idstringyes
statuspending_humanyes
expires_in_hoursintegeryes

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:

FieldTypeRequiredNotes
productSlugstringyes
channelstringyes
kindstringyes
urlstringno
utmCampaignstringno
notestringno

200 — Logged.

Response:

FieldTypeRequiredNotes
oktrueyes
idstringyes

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.

ParameterInTypeRequired
slugpathstringyes

200 — Dashboards, oldest first.

Response (array of):

FieldTypeRequiredNotes
idstringyes
product_slugstringyes
namestringyes
descriptionstring \nullyes
specobjectyes
created_bystringyes
updated_bystring \nullyes
created_atstringyes
updated_atstringyes

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:

FieldTypeRequiredNotes
namestringyes
descriptionstringno
specobjectno
promptstringnoDescribe the dashboard in plain words; the Keeper composes a spec from it. Ignored when spec is given.
ParameterInTypeRequired
slugpathstringyes

200 — Created.

Response:

FieldTypeRequiredNotes
idstringyes
product_slugstringyes
namestringyes
descriptionstring \nullyes
specobjectyes
created_bystringyes
updated_bystring \nullyes
created_atstringyes
updated_atstringyes

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:

FieldTypeRequiredNotes
promptstringyes
ParameterInTypeRequired
slugpathstringyes

200 — The draft.

Response:

FieldTypeRequiredNotes
namestringyes
specobjectyes
matchedstring[]yesWhat the composer recognised in the prompt — shown so the draft is honest about what it understood.

POST /dashboards/{id}

Update a dashboard.

Request body:

FieldTypeRequiredNotes
namestringno
descriptionstring \nullno
specobjectno
ParameterInTypeRequired
idpathstringyes

200 — Updated.

Response:

FieldTypeRequiredNotes
idstringyes
product_slugstringyes
namestringyes
descriptionstring \nullyes
specobjectyes
created_bystringyes
updated_bystring \nullyes
created_atstringyes
updated_atstringyes

404 — Not yours or doesn’t exist.

POST /dashboards/{id}/delete

Delete a dashboard. Dashboards are configuration, not facts — deletion is real and audited.

ParameterInTypeRequired
idpathstringyes

200 — Deleted.

Response:

FieldTypeRequiredNotes
oktrueyes

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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — The report.

Response:

FieldTypeRequiredNotes
window_daysintegeryes
channelsobject[]yesIncludes ‘unattributed’ as its own row — never smeared across channels.
totalsobjectyes
primary_goalobject \nullno
coverageobjectyesHow 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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno
channelquerystringno
limitqueryintegerno

200 — The sales list.

Response:

FieldTypeRequiredNotes
window_daysintegeryes
totalintegeryesSales in the window matching the filter, before LIMIT.
salesobject[]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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno
levelquerycampaign · ad_group · keyword · adno
limitqueryintegerno

200 — The report.

Response:

FieldTypeRequiredNotes
window_daysintegeryes
levelcampaign · ad_group · keyword · adyes
rowsobject[]yesIncludes 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).
totalsobjectyes
coverageobjectyesHow 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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — Rows, oldest first.

Response (array of):

FieldTypeRequiredNotes
daystringyesYYYY-MM-DD
spend_centsintegeryes
revenue_centsintegeryes
visitsintegeryes
leadsintegeryes
customersintegeryes
search_clicksintegeryes
search_impressionsintegeryes
ai_visibilitynumber \nullyes
social_impressionsintegeryes
social_engagementintegeryes
social_followersintegeryes

funnel

GET /products/{slug}/funnel/definition

The project’s funnel definition + tracking status per stage.

ParameterInTypeRequired
slugpathstringyes

200 — Steps and stage availability.

Response:

FieldTypeRequiredNotes
stepsobject[]yes
is_defaultbooleanyesTrue while the project is on the built-in ladder (nothing saved yet).
availableobject[]yesEvery 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:

FieldTypeRequiredNotes
stepsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
stepsobject[]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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — Rows, oldest first. Days with no events are absent.

Response (array of):

FieldTypeRequiredNotes
daystringyes
stageimpression · visit · engaged · lead · qualified · signup · activated · converted · payment · churnedyes
volumeintegeryes

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.

ParameterInTypeRequired
slugpathstringyes

200 — Definitions.

Response:

FieldTypeRequiredNotes
conversionsobject[]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:

FieldTypeRequiredNotes
keystringyesStable slug, e.g. ‘car-sold’ or ‘waitlist-signup’. Upserts replace the definition with this key.
labelstringyes
tierprimary · secondaryyes
stageimpression · visit · engaged · lead · qualified · signup · activated · converted · payment · churnedyesThe canonical stage whose events count as this conversion.
sourcemysql · postgres · mongo · ga4 · stripenoRestrict to one source (‘mysql’ | ‘postgres’ | ‘mongo’ | ‘ga4’ | ‘stripe’); omit to count the stage from any source.
event_namestring \nullno
value_modenone · fixed · transactionno
fixed_value_centsinteger \nullno
currencystring \nullno
score_modenone · grade · numericno‘none’ (default), ‘grade’ (A/B/C/D lead qualification), or ‘numeric’ (point scoring).
target_cpa_centsinteger \nullno
grade_weightsobject \nullno
is_activebooleanno
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes
conversionobjectyes

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.

ParameterInTypeRequired
slugpathstringyes
keypathstringyes

200 — Deleted.

Response:

FieldTypeRequiredNotes
oktrueyes

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

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — The report.

Response:

FieldTypeRequiredNotes
window_daysintegeryes
conversionsobject[]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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — The report.

Response:

FieldTypeRequiredNotes
product_slugstringyes
window_daysintegeryes
funnelobjectyes
trendobject[]yes
queriesobject[]yes
ranksobject[]yes
ai_visibilityobjectyes
authorityobjectyes
sourcesobject[]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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — The report.

Response:

FieldTypeRequiredNotes
product_slugstringyes
window_daysintegeryes
audienceobjectyes
networksobject[]yes
trendobject[]yes
postsobject[]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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno

200 — The report.

Response:

FieldTypeRequiredNotes
product_slugstringyes
generated_atstringyes
window_daysintegeryes
scoreinteger \nullyes
gradestring \nullyes
categoriesobject[]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:

FieldTypeRequiredNotes
jobsobject[]yes
sourcesobject[]yes
queueobject \nullyes
attributionobject[]yes
productsobject[]yes
nowstringyes

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.

ParameterInTypeRequired
slugpathstringyes
daysqueryintegerno
toolquerycursor · claude_code · lovable · bolt · v0no

200 — Ranked actions.

Response:

FieldTypeRequiredNotes
product_slugstringyes
product_namestringyes
generated_atstringyes
window_daysintegeryes
actionsobject[]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:

FieldTypeRequiredNotes
toolcursor · claude_code · lovable · bolt · v0no
daysintegerno
ParameterInTypeRequired
slugpathstringyes
idpathstringyes

200 — The prompt.

Response:

FieldTypeRequiredNotes
idstringyes
toolcursor · claude_code · lovable · bolt · v0yes
promptstringyes
headlinestringyes
revenue_impact_centsinteger \nullyes
currencystringyes

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:

FieldTypeRequiredNotes
eventsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Accepted (duplicates silently dropped).

Response:

FieldTypeRequiredNotes
oktrueyes
receivedintegeryes

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:

FieldTypeRequiredNotes
keystringyesThe project’s publishable write key.
eventsobject[]yes

200 — Accepted.

Response:

FieldTypeRequiredNotes
oktrueyes
receivedintegeryes

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.

ParameterInTypeRequired
slugpathstringyes

200 — Key or null if none minted yet.

Response:

FieldTypeRequiredNotes
keystring \nullyes
created_atstring \nullyes
snippetstring \nullyes
gtm_htmlstring \nullyes
curl_examplestringyes
ingest_urlstringyes
gtm_connectedbooleanyes
gtm_deployobject \nullyes

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.

ParameterInTypeRequired
slugpathstringyes

200 — The new key.

Response:

FieldTypeRequiredNotes
oktrueyes
keystringyesThe new fk_pub_… key. Publishable — it ships in page source.
created_atstringyes
rotatedbooleanyes
snippetstringyes

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.

ParameterInTypeRequired
slugpathstringyes

200 — Published.

Response:

FieldTypeRequiredNotes
oktrueyes
workspace_namestringyes
version_namestringyes
publishedbooleanyes

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.

ParameterInTypeRequired
slugpathstringyes

200 — Recorded.

Response:

FieldTypeRequiredNotes
oktrueyes
first_timebooleanyes

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.

ParameterInTypeRequired
productquerystringno
daysqueryintegerno
limitqueryintegerno

200 — Events, newest first.

Response (array of):

FieldTypeRequiredNotes
idintegeryes
occurred_atstringyes
actorstringyesA human’s email, or ‘keeper’ / ‘system’ / ‘policy’.
viastringyesdashboard | mcp | api | job | chat
actionstringyes
product_slugstring \nullyes
subject_refstring \nullyes
card_headlinestring \nullyes
card_typestring \nullyes
payloadobjectyes

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:

FieldTypeRequiredNotes
planfree · indie · growthyes
stored_planfree · indie · growthyes
plan_statusactive · trialing · past_due · canceledyes
trial_ends_atstring \nullyes
trial_days_leftinteger \nullyes
plan_period_endstring \nullyes
billing_availablebooleanyes
has_customerbooleanyes
subscribedbooleanyes
member_roleowner · memberyes
periodstringyes
usageobjectyes
limitsobjectyes
billable_productsintegeryes
quantityintegeryes
product_linesobject[]yes
featuresobjectyes
plansobject[]yes

POST /billing/checkout

Start Stripe Checkout for Indie or Growth.

Request body:

FieldTypeRequiredNotes
planindie · growthyes

200 — Checkout URL.

Response:

FieldTypeRequiredNotes
urlstringyes

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:

FieldTypeRequiredNotes
urlstringyes

POST /billing/webhook

Public — no authentication.

Stripe webhook (public; signature-verified).

200 — Received.

Response:

FieldTypeRequiredNotes
oktrueyes

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.

ParameterInTypeRequired
slugpathstringyes
weeksqueryintegerno
currentquery1no

200 — The matrix.

Response:

FieldTypeRequiredNotes
product_slugstringyes
product_namestringyes
currencystringyes
weeksobject[]yes
sectionsobject[]yes
templatestring \nullyes
spreadsheet_idstring \nullyes
share_urlstring \nullyes
sheets_enabledbooleanyesWhether this server can push to Google Sheets.
catalogobject[]yes
clampedbooleanyes

POST /products/{slug}/scorecard

Save the scorecard definition.

Request body:

FieldTypeRequiredNotes
sectionsobject[]yes
templatestring \nullno
ParameterInTypeRequired
slugpathstringyes

200 — Updated matrix.

Response:

FieldTypeRequiredNotes
product_slugstringyes
product_namestringyes
currencystringyes
weeksobject[]yes
sectionsobject[]yes
templatestring \nullyes
spreadsheet_idstring \nullyes
share_urlstring \nullyes
sheets_enabledbooleanyesWhether this server can push to Google Sheets.
catalogobject[]yes
clampedbooleanyes

GET /products/{slug}/scorecard/catalog

Scorecard metric catalog and templates.

ParameterInTypeRequired
slugpathstringyes

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.

ParameterInTypeRequired
slugpathstringyes
weeksqueryintegerno

200 — The analysis.

Response:

FieldTypeRequiredNotes
highlightsobject[]yes
cardsobject[]yes
aibooleanyesTrue when the LLM enriched the analysis (Growth).
modelstring \nullyes
generated_atstringyes

POST /products/{slug}/scorecard/template

Replace the scorecard with a named template.

Request body:

FieldTypeRequiredNotes
templatedefault · saas_waitlist · paid_acquisition · content_ledyes
ParameterInTypeRequired
slugpathstringyes

200 — Updated matrix.

Response:

FieldTypeRequiredNotes
product_slugstringyes
product_namestringyes
currencystringyes
weeksobject[]yes
sectionsobject[]yes
templatestring \nullyes
spreadsheet_idstring \nullyes
share_urlstring \nullyes
sheets_enabledbooleanyesWhether this server can push to Google Sheets.
catalogobject[]yes
clampedbooleanyes

POST /products/{slug}/scorecard/values

Write manual scorecard cells.

Request body:

FieldTypeRequiredNotes
cellsobject[]yes
ParameterInTypeRequired
slugpathstringyes

200 — Saved.

Response:

FieldTypeRequiredNotes
oktrueyes

POST /products/{slug}/scorecard/suggest

LLM metric suggestions (Indie+). Proposals only — never auto-applied. Grounded against the catalog.

Request body:

FieldTypeRequiredNotes
promptstringno
ParameterInTypeRequired
slugpathstringyes

200 — Suggested metrics.

GET /products/{slug}/scorecard/export.csv

CSV export of the scorecard (Growth).

ParameterInTypeRequired
slugpathstringyes
weeksqueryintegerno
currentquery1no

200 — text/csv

GET /products/{slug}/scorecard/export.xlsx

Excel export of the scorecard (Growth).

ParameterInTypeRequired
slugpathstringyes
weeksqueryintegerno
currentquery1no

200 — application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

POST /products/{slug}/scorecard/share

Mint a public read-only share link.

ParameterInTypeRequired
slugpathstringyes

200 — Share URL.

POST /products/{slug}/scorecard/share/revoke

Revoke the public share link.

ParameterInTypeRequired
slugpathstringyes

200 — Revoked.

Response:

FieldTypeRequiredNotes
oktrueyes

POST /products/{slug}/scorecard/sheets/sync

Push the scorecard to Google Sheets (Growth, env-gated).

ParameterInTypeRequired
slugpathstringyes

200 — Spreadsheet id and URL.

GET /t/scorecard/{token}

Public — no authentication.

Public read-only scorecard (share token).

ParameterInTypeRequired
tokenpathstringyes

200 — The matrix.

Response:

FieldTypeRequiredNotes
product_slugstringyes
product_namestringyes
currencystringyes
weeksobject[]yes
sectionsobject[]yes
templatestring \nullyes
spreadsheet_idstring \nullyes
share_urlstring \nullyes
sheets_enabledbooleanyesWhether this server can push to Google Sheets.
catalogobject[]yes
clampedbooleanyes

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:

FieldTypeRequiredNotes
messagesobject[]yes
streambooleannoWhen true, the server replies as text/event-stream (tool / delta / done / error events).
sectionstringnoThe 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:

FieldTypeRequiredNotes
replystringyes
modelstringyes
tool_callsobject[]yes
suggested_actionsobject[]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:

FieldTypeRequiredNotes
messagesobject[]yes
streambooleannoWhen true, the server replies as text/event-stream (tool / delta / done / error events).
sectionstringnoThe dashboard section the human is looking at (dashboard, funnel, …). Shapes the Keeper’s first look.
ParameterInTypeRequired
slugpathstringyes

200 — The Keeper’s reply (or an SSE stream when stream=true).

Response:

FieldTypeRequiredNotes
replystringyes
modelstringyes
tool_callsobject[]yes
suggested_actionsobject[]yes

503 — OPENROUTER_API_KEY is not set.