# Event tracking

FunnelKeeper can pull conversions from a database or GA4. It can also **receive**
them. Two paths, one write:

| Path | Auth | Source namespace |
|---|---|---|
| `POST /products/{slug}/events` | account API key (`fk_live_…`) | `api:<slug>` |
| `POST /t/events` | publishable write key (`fk_pub_…`) in the body | `web:<slug>` |

Both go through the same append-only ingest as the database adapters: identity
stitch, channel resolution, optional transaction. Corrections are new rows, never
updates.

## Event shape

Every event needs a **canonical stage** (`lead`, `converted`, `payment`, …) and a
**`dedupe_id`**. Retries with the same id are silent no-ops. The free-form event
name is optional and lands in `props.event` — same convention as mapped GA4
events.

```json
{
  "stage": "converted",
  "dedupe_id": "order-123",
  "name": "purchase",
  "identity": { "email": "buyer@example.com", "external_id": "cust_9" },
  "attribution": { "utm_source": "google", "gclid": "…" },
  "value": { "amount_cents": 9900, "currency": "USD", "kind": "charge" }
}
```

`value.amount_cents` is integer cents. Currency defaults to the product's.
Put revenue on exactly one stage per source ref — a second value on the same
`dedupe_id` would share the transaction key and drop.

## Browser snippet

Mint a write key from Integrations → Event tracking (or `fk tracking-key rotate`,
or MCP `rotate_tracking_key`). Then:

```html
<script src="https://api.funnelkeeper.com/fk.js" data-key="fk_pub_…" async></script>
<script>
  fk.identify({ email: "buyer@example.com" });
  fk.track("converted", { name: "purchase", value_cents: 9900, currency: "USD" });
</script>
```

On load the snippet captures `utm_*`, `gclid`, `fbclid`, and `document.referrer`.
First-touch is persisted in `localStorage` and a cookie on the registrable
domain (so `www.example.com` → `app.example.com` keeps the click). Last-touch
updates on each landing. A per-browser anonymous id is the `external_id`
fallback so pre-email conversions still stitch. There is no auto-pageview —
GA4 already owns traffic.

`fk.track` / `fk.identify` may be called before `/fk.js` finishes loading: install
a stub that queues onto `fk.q`, then the snippet flushes it. Pass `dedupe_id` when
the same fact is also sent server-side so retries collapse.

The key is publishable on purpose. A leaked `fk_pub_…` can only write events into
that product. Rotate it from Integrations if it leaks; the old key dies immediately.

## Google Tag Manager

**Manual.** Integrations copies a Custom HTML tag. In GTM: Tags → New → Custom
HTML → paste → trigger **All Pages** → Submit / Publish.

**Auto-deploy.** If GTM is connected, the button is on the Google Tag Manager
card itself (**Deploy tag to container**) and under Event tracking (**Deploy via
Tag Manager**); both ask you to confirm first, and both mint a write key with the
tag if the product has none. FunnelKeeper creates a workspace, adds the snippet on
All Pages, and publishes. This needs `tagmanager.edit.containers` and
`tagmanager.publish` on the Google grant. A grant minted before those scopes
existed returns 403 — reconnect Google (same connect flow) and retry.

The daily tag-audit job then reports whether the snippet is in the live container.

## Server-side

Use the account API key. Dashboard, CLI, and MCP share this route.

```bash
curl -X POST https://api.funnelkeeper.com/products/demo-product/events \
  -H "Authorization: Bearer fk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"events":[{"stage":"converted","dedupe_id":"order-123","name":"purchase","value":{"amount_cents":9900}}]}'
```

```
fk track --product demo-product --stage converted --dedupe order-123 --cents 9900 --name purchase
```

MCP tool: `track_events`. Batch cap 500 on the authenticated route, 25 on the
browser route.

## What this is not

The snippet is not a replacement for GA4. It does not auto-track pageviews or
compete with a CDP. It is the push path for funnel facts you already know —
a signup, a sale, a car sold — with attribution captured at the moment they
happen.
