API reference

Base URL https://funnelkeeper.fly.dev · 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.

Request body:

FieldTypeRequiredNotes
emailstringyes
passwordstringyesAt least 10 characters.

200 — Verification email sent (or already registered).

Response:

FieldTypeRequiredNotes
oktrueyes
statusverification_sentyes

429 — Rate limited.

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
passwordstringyes

200 — Session created.

Response:

FieldTypeRequiredNotes
tokenstringyesSession bearer token (fk_sess_…). Expires after 7 idle days.
expires_atstringyes
accountobjectyes

401 — Wrong credentials.

403 — Email not yet verified.

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 — products 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
verifiedbooleanyes
created_atstringyes

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; the invitee sets their password at /auth/accept-invite.

Request body:

FieldTypeRequiredNotes
emailstringyes
member_roleowner · memberno

200 — Invited.

Response:

FieldTypeRequiredNotes
oktrueyes
user_idstringyes
invite_urlstringyes
expires_in_hoursintegeryes

403 — Not an owner.

409 — Email already has a login.

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.

POST /auth/accept-invite

Public — no authentication.

Accept an invite. Public — the single-use invite token is the credential. Sets the password and returns a logged-in session.

Request body:

FieldTypeRequiredNotes
tokenstringyes
passwordstringyes
namestringno

200 — Joined; session created.

Response:

FieldTypeRequiredNotes
tokenstringyesSession bearer token (fk_sess_…). Expires after 7 idle days.
expires_atstringyes
accountobjectyes

400 — Invalid or expired invite.

portfolio

GET /portfolio

All products in your account. One row per product: spend, traffic, revenue, CAC, LTV:CAC, gate status, pending Keeper cards. Operators see every account.

200 — Products.

Response (array of):

FieldTypeRequiredNotes
product_idstringyes
slugstringyes
namestringyes
stagevalidation · live · killedyes
currencystringyes
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 product.

Request body:

FieldTypeRequiredNotes
namestringyes
slugstringyes
domainstringno
currencystringno

200 — Created.

Response:

FieldTypeRequiredNotes
idstringyes
slugstringyes
namestringyes
currencystringyes
stagestringyes

409 — Slug taken.

GET /products/{slug}

One product’s portfolio row.

ParameterInTypeRequired
slugpathstringyes

200 — The row.

Response:

FieldTypeRequiredNotes
product_idstringyes
slugstringyes
namestringyes
stagevalidation · live · killedyes
currencystringyes
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 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
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, and google_ads together. Then poll /connect/google/status.

Request body:

FieldTypeRequiredNotes
product_slugstringyes
kindsga4 · gtm · google_ads[]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
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.

Request body:

FieldTypeRequiredNotes
statestringyes
ga4_property_idstringno
gtm_container_pathstringno
ads_customer_idstringno

200 — Activated.

Response:

FieldTypeRequiredNotes
oktrueyes
activatedstring[]yes

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/{kind}/test

Test a connection.

ParameterInTypeRequired
slugpathstringyes
kindpathga4 · gtm · google_ads · semrush · mysqlyes

200 — ok + human-readable detail.

Response:

FieldTypeRequiredNotes
okbooleanyes
detailstringyes

agent

GET /products/{slug}/spend

Daily spend by channel and campaign. Includes the product’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 product’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
coverageobjectyesHow complete the attribution inputs are — the honesty panel.

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

funnel

GET /products/{slug}/funnel/definition

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

ParameterInTypeRequired
slugpathstringyes

200 — Steps and stage availability.

Response:

FieldTypeRequiredNotes
stepsobject[]yes
is_defaultbooleanyesTrue while the product 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

health

GET /products/{slug}/health-report

Audit the product: 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

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. Product-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
actionstringyes
product_slugstring \nullyes
subject_refstring \nullyes
card_headlinestring \nullyes
card_typestring \nullyes
payloadobjectyes