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:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
password | string | yes | At least 10 characters. |
200 — Verification email sent (or already registered).
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
status | verification_sent | yes |
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.
| Parameter | In | Type | Required |
|---|---|---|---|
token | query | string | yes |
302 — Verified; redirecting to the dashboard.
400 — Invalid or expired token.
POST /auth/login
Public — no authentication.
Log in.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
password | string | yes |
200 — Session created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | Session bearer token (fk_sess_…). Expires after 7 idle days. |
expires_at | string | yes | |
account | object | yes |
401 — Wrong credentials.
403 — Email not yet verified.
POST /auth/logout
Log out (revoke this session).
200 — Session revoked.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
GET /auth/me
Who am I.
200 — The authenticated account.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | The ACCOUNT (tenant) id — products and API keys hang off this. |
email | string | yes | The authenticated person’s email. |
role | customer · operator | yes | The tenant’s role. |
member_role | owner · member | no | The person’s role within the account. |
name | string \ | null | no |
verified | boolean | yes | |
created_at | string | yes |
GET /auth/keys
List API keys.
200 — Keys (prefixes only).
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
name | string | yes | |
prefix | string | yes | |
created_at | string | yes | |
last_used_at | string \ | null | yes |
POST /auth/keys
Create an API key. The response contains the full key exactly once.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no |
200 — Created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
name | string | yes | |
key | string | yes | The full API key (fk_live_…). Shown exactly once — store it now. |
POST /auth/keys/{id}/revoke
Revoke an API key.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Revoked.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
team
GET /team
List the account’s team.
200 — Members, oldest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
email | string | yes | |
name | string \ | null | yes |
member_role | owner · member | yes | |
status | active · invited · disabled | yes | |
created_at | string | yes | |
last_seen_at | string \ | null | yes |
POST /team/invites
Invite a person to the account. Owners only. Returns the single-use invite URL (also emailed when an email service is configured) — valid 7 days; the invitee sets their password at /auth/accept-invite.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
member_role | owner · member | no |
200 — Invited.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
user_id | string | yes | |
invite_url | string | yes | |
expires_in_hours | integer | yes |
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.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
400 — Guard rail refused it.
403 — Not an owner.
POST /team/{id}/enable
Re-enable a team member. Owners only.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
400 — Guard rail refused it.
403 — Not an owner.
POST /team/{id}/role
Change a member’s role. Owners only. The last active owner can’t be demoted.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
member_role | owner · member | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Changed.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
400 — Guard rail refused it.
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:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | |
password | string | yes | |
name | string | no |
200 — Joined; session created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | Session bearer token (fk_sess_…). Expires after 7 idle days. |
expires_at | string | yes | |
account | object | yes |
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):
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | string | yes | |
slug | string | yes | |
name | string | yes | |
stage | validation · live · killed | yes | |
currency | string | yes | |
gate_result | pass · fail · pivot | yes | |
gate_due_date | string \ | null | yes |
spend_7d_cents | integer | yes | |
spend_30d_cents | integer | yes | |
spend_prev_30d_cents | integer | yes | |
visits_30d | integer | yes | |
leads_30d | integer | yes | |
revenue_30d_cents | integer | yes | |
revenue_prev_30d_cents | integer | yes | |
customers_30d | integer | yes | |
cac_cents | integer \ | null | yes |
ltv_cents | integer \ | null | yes |
ltv_cac | number \ | null | yes |
pending_cards | integer | yes |
POST /products
Create a product.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
slug | string | yes | |
domain | string | no | |
currency | string | no |
200 — Created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
slug | string | yes | |
name | string | yes | |
currency | string | yes | |
stage | string | yes |
409 — Slug taken.
GET /products/{slug}
One product’s portfolio row.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The row.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | string | yes | |
slug | string | yes | |
name | string | yes | |
stage | validation · live · killed | yes | |
currency | string | yes | |
gate_result | pass · fail · pivot | yes | |
gate_due_date | string \ | null | yes |
spend_7d_cents | integer | yes | |
spend_30d_cents | integer | yes | |
spend_prev_30d_cents | integer | yes | |
visits_30d | integer | yes | |
leads_30d | integer | yes | |
revenue_30d_cents | integer | yes | |
revenue_prev_30d_cents | integer | yes | |
customers_30d | integer | yes | |
cac_cents | integer \ | null | yes |
ltv_cents | integer \ | null | yes |
ltv_cac | number \ | null | yes |
pending_cards | integer | yes |
404 — Not yours or doesn’t exist.
GET /products/{slug}/funnel
Funnel stage × channel volumes.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Rows.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
stage | string | yes | |
channel | string \ | null | yes |
events | integer | yes | |
volume | integer | yes |
GET /products/{slug}/payback
Cohort payback curves + per-channel CAC.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — cohorts: cumulative revenue per cohort-day; cac: 90-day spend / first-touch customers per channel.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
cohorts | object[] | yes | |
cac | object[] | yes |
queue
GET /queue
Pending Keeper cards. The HITL spine. The Keeper proposes; only a human resolution moves a card. Nothing changes spend without a tap.
200 — Cards.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string \ | null | yes |
card_type | proposal · insight · alert · gate_result · policy_block | yes | |
headline | string | yes | |
metric_line | string | yes | |
detail | string \ | null | yes |
proposed_by | keeper · system | yes | |
action_spec | object \ | null | yes |
status | string | yes | |
created_at | string | yes | |
expires_at | string \ | null | yes |
POST /queue/{id}/approve
Approve a card. Approving a spend proposal is policy-checked (daily caps, network walls, portfolio ceiling) and can change ad spend. resolved_by records the authenticated human.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
POST /queue/{id}/reject
Dismiss a card. Removes the card from the queue; recorded with the authenticated actor.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
POST /queue/{id}/snooze
Snooze a card. Reappears after snoozeHours (default 24).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
POST /queue/{id}/restore
Restore a snoozed/expired card. Only ever returns a card to pending; can never resolve one.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
actor | string | no | Operator-only override; everyone else IS the actor (their authenticated email). |
snoozeHours | integer | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Done.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
403 — Policy wall refused it.
404 — No such pending card in your account.
connections
GET /connections
Integration inventory. Status, redacted config, last sync/error per source. Secrets never appear here.
200 — Connections.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
kind | string | yes | |
status | pending · active · error · disabled | yes | |
config | object \ | null | yes |
last_sync_at | string \ | null | yes |
last_error | string \ | null | yes |
product_slug | string | yes |
POST /connect/google/start
Begin connecting Google sources. Returns an authorization URL for a HUMAN to open (agents: hand it to your user). One Google grant serves ga4, gtm, and google_ads together. Then poll /connect/google/status.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
kinds | ga4 · gtm · google_ads[] | yes | |
client | dashboard · cli · mcp · api | no |
200 — The URL and the state to poll with.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
state | string | yes | |
auth_url | string | yes | |
expires_at | string | yes |
GET /connect/google/status
Poll an in-progress Google connection.
| Parameter | In | Type | Required |
|---|---|---|---|
state | query | string | yes |
200 — pending | complete (with entity options for the select step) | error.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
status | pending · complete · error | yes | |
error | string | no | |
results | object[] | no | |
options | object | no |
POST /connect/google/select
Finish a Google connection. Choose which GA4 property / GTM container / Ads customer to track, from the options in the status payload.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
state | string | yes | |
ga4_property_id | string | no | |
gtm_container_path | string | no | |
ads_customer_id | string | no |
200 — Activated.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
activated | string[] | 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:
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | |
domain | string | yes | |
database | string | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Stored.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
connection_id | string | yes |
POST /products/{slug}/connections/{kind}/test
Test a connection.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
kind | path | ga4 · gtm · google_ads · semrush · mysql | yes |
200 — ok + human-readable detail.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | boolean | yes | |
detail | string | yes |
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.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Spend rows + caps.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
rows | object[] | yes | |
caps | object | yes |
POST /products/{slug}/proposals
Propose a spend change (budget.propose). The ONLY write that can lead to an ad-network change, and it never executes directly: it queues a card for a HUMAN to approve. Policy caps are enforced here, at the boundary — an over-cap proposal is rejected with policy_code rather than parked. Proposals expire after 72 hours.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
network | meta · google | yes | |
campaign_id | string | no | |
from_cents | integer | no | |
to_cents | integer | yes | Proposed daily cap, integer cents. |
rationale | string | yes | Why. Shown to the human on the card. |
rollback_if | object | no | Condition under which the change should be reverted, e.g. {“cac_usd_above”: 80, “window_days”: 5}. |
headline | string | no |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
202 — Queued for a human.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
proposal_id | string | yes | |
status | pending_human | yes | |
expires_in_hours | integer | yes |
422 — Refused by the policy engine.
POST /distribution
Log a distribution event (events.log). Record outreach an agent or human performed — a post shipped, a listing submitted, an email sent — so its traffic can be joined back via utm_campaign.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
productSlug | string | yes | |
channel | string | yes | |
kind | string | yes | |
url | string | no | |
utmCampaign | string | no | |
note | string | no |
200 — Logged.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
id | string | yes |
dashboards
GET /products/{slug}/dashboards
List a product’s dashboards. User- and agent-built dashboards. The built-in Overview is not stored — clients render it from a constant spec.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Dashboards, oldest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string | yes | |
name | string | yes | |
description | string \ | null | yes |
spec | object | yes | |
created_by | string | yes | |
updated_by | string \ | null | yes |
created_at | string | yes | |
updated_at | string | yes |
POST /products/{slug}/dashboards
Create a dashboard. Pass a full widget spec to build it exactly (the agent path), a prompt to have the Keeper compose one, or neither to start blank. Audited.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
description | string | no | |
spec | object | no | |
prompt | string | no | Describe the dashboard in plain words; the Keeper composes a spec from it. Ignored when spec is given. |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Created.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string | yes | |
name | string | yes | |
description | string \ | null | yes |
spec | object | yes | |
created_by | string | yes | |
updated_by | string \ | null | yes |
created_at | string | yes | |
updated_at | string | yes |
400 — Spec failed validation.
POST /products/{slug}/dashboards/generate
Draft a dashboard from a description. Deterministic composer: maps the words in your description onto known widgets. Returns a draft spec — nothing is saved until you POST it to /dashboards.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
prompt | string | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — The draft.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
spec | object | yes | |
matched | string[] | yes | What the composer recognised in the prompt — shown so the draft is honest about what it understood. |
POST /dashboards/{id}
Update a dashboard.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | |
description | string \ | null | no |
spec | object | no |
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Updated.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
product_slug | string | yes | |
name | string | yes | |
description | string \ | null | yes |
spec | object | yes | |
created_by | string | yes | |
updated_by | string \ | null | yes |
created_at | string | yes | |
updated_at | string | yes |
404 — Not yours or doesn’t exist.
POST /dashboards/{id}/delete
Delete a dashboard. Dashboards are configuration, not facts — deletion is real and audited.
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | string | yes |
200 — Deleted.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes |
attribution
GET /products/{slug}/attribution
Revenue by channel under first- and last-touch models. Spend, first-touch and last-touch revenue, customers, CAC and ROAS per channel, plus attribution coverage. Unattributed revenue is its own row, shown honestly.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
window_days | integer | yes | |
channels | object[] | yes | Includes ‘unattributed’ as its own row — never smeared across channels. |
totals | object | yes | |
coverage | object | yes | How 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.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Rows, oldest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
day | string | yes | YYYY-MM-DD |
spend_cents | integer | yes | |
revenue_cents | integer | yes | |
visits | integer | yes | |
leads | integer | yes | |
customers | integer | yes |
funnel
GET /products/{slug}/funnel/definition
The product’s funnel definition + tracking status per stage.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Steps and stage availability.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
steps | object[] | yes | |
is_default | boolean | yes | True while the product is on the built-in ladder (nothing saved yet). |
available | object[] | yes | Every canonical stage with its recent tracking status — what’s wired vs what would render empty. |
POST /products/{slug}/funnel/definition
Save the funnel definition. Ordered, labelled steps drawn from the canonical stage taxonomy. Audited; the funnel page and health checks use it immediately.
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
steps | object[] | yes |
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
200 — Saved.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
ok | true | yes | |
steps | object[] | yes |
400 — Invalid steps.
GET /products/{slug}/funnel/series
Daily volume per funnel stage. Powers ‘the funnel today vs over time’: clients sum windows for side-by-side comparison and draw per-step trends from the same rows.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — Rows, oldest first. Days with no events are absent.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
day | string | yes | |
stage | impression · visit · engaged · lead · qualified · signup · activated · converted · payment · churned | yes | |
volume | integer | yes |
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.
| Parameter | In | Type | Required |
|---|---|---|---|
slug | path | string | yes |
days | query | integer | no |
200 — The report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
product_slug | string | yes | |
generated_at | string | yes | |
window_days | integer | yes | |
score | integer \ | null | yes |
grade | string \ | null | yes |
categories | object[] | yes |
GET /health
Pipeline health. Job runs (with consecutive failures), per-source data freshness, queue backlog, and the share of revenue that cannot be attributed — shown honestly, never smeared across channels.
200 — Health report.
Response:
| Field | Type | Required | Notes |
|---|---|---|---|
jobs | object[] | yes | |
sources | object[] | yes | |
queue | object \ | null | yes |
attribution | object[] | yes | |
products | object[] | yes | |
now | string | yes |
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.
| Parameter | In | Type | Required |
|---|---|---|---|
product | query | string | no |
days | query | integer | no |
limit | query | integer | no |
200 — Events, newest first.
Response (array of):
| Field | Type | Required | Notes |
|---|---|---|---|
id | integer | yes | |
occurred_at | string | yes | |
actor | string | yes | A human’s email, or ‘keeper’ / ‘system’ / ‘policy’. |
via | string | yes | dashboard | mcp | api | job |
action | string | yes | |
product_slug | string \ | null | yes |
subject_ref | string \ | null | yes |
card_headline | string \ | null | yes |
card_type | string \ | null | yes |
payload | object | yes |