# API reference

Base URL `https://funnelkeeper.fly.dev` · Version 0.2.0 · Machine-readable spec at [/openapi.json](/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 | Null when no customers were acquired in the window — never a fake zero. |
| `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 | Null when no customers were acquired in the window — never a fake zero. |
| `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 | 0–100 across all scoreable checks; null until any data exists. |
| `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 | When the subject is a Keeper card, its headline. |
| `card_type` | string \\| null | yes |  |
| `payload` | object | yes |  |


