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.
{
"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:
<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.
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.