# Welcome

API-first, privacy-respecting analytics for vibe coders, small startups, and AI agents.

Millimetric is a hosted analytics product where the **HTTP API is the source of truth**. You call it from your server, your browser, or an AI agent — every client speaks the same protocol.

## What you get

* **One HTTP API**, three first-class clients (browser SDK, Node SDK, MCP server for AI agents).
* **No cookies** — `anonymous_id` lives in `localStorage` and only when *you* call `track()`.
* **No raw IPs at rest** — IPs are HMAC'd with a daily-rotating salt; only country survives.
* **Smart attribution** — a server-side classifier separates Facebook *social* from Facebook *paid*, Google organic from Google Ads, and every other common combination, with a confidence label and an audit trail.
* **First-class MCP** — agents emit events with `track_event` and query their own data with `top_sources`, `get_stats`, `query_events`.

## Pick your path

| If you want to…                                                | Go here                                   |
| -------------------------------------------------------------- | ----------------------------------------- |
| See a working example in 60 seconds                            | [Quickstart](/quickstart)                 |
| Understand what an event is                                    | [Events](/core-concepts/events)           |
| Understand what to put in `properties`                         | [Properties](/core-concepts/properties)   |
| Stitch anonymous → known users                                 | [Identities](/core-concepts/identities)   |
| Know how Facebook *social* vs *paid* gets decided              | [Attribution](/core-concepts/attribution) |
| See exactly what gets stored                                   | [Privacy](/core-concepts/privacy)         |
| Reference every endpoint                                       | [API overview](/api-reference/overview)   |
| Drop the SDK into React / Next / Vue / Svelte                  | [Framework recipes](/sdks/frameworks)     |
| Connect an AI agent                                            | [MCP server](/mcp-for-ai-agents/overview) |
| Build a signup funnel, e-commerce dashboard, attribution model | [Recipes](/recipes/recipes)               |

## Three keys you'll meet everywhere

| Key prefix  | Where it lives                     | What it can do                   |
| ----------- | ---------------------------------- | -------------------------------- |
| `pk_live_…` | Browsers                           | Ingest only — origin-allowlisted |
| `sk_live_…` | Servers, scripts, anywhere private | Ingest only — full powers        |
| `rk_live_…` | Servers, MCP clients, dashboards   | Read only                        |

Mint them in the dashboard (`apps/web`). You'll see the secret exactly once.

## Send your first event

```bash
curl -X POST https://api.millimetric.ai/v1/track \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "event": "signup",
    "anonymous_id": "u_abc",
    "user_id": "user_42",
    "properties": { "plan": "free" }
  }'
```

That's the whole API surface for ingest. Add `url` + `referrer` and the [classifier](/core-concepts/attribution) gives you `source / medium / campaign` for free.

The next thing to read is the [Quickstart](/quickstart), then [Events](/core-concepts/events) and [Properties](/core-concepts/properties) for the conceptual ground floor.


# Quickstart

Three ways to send events: `curl` (or any HTTP client), the browser SDK, the Node SDK, or an AI agent via MCP. They all hit the same API.

## 1. Get an API key

In the dashboard, create a project. You'll get three keys:

| Key         | Use it from                        | What it can do |
| ----------- | ---------------------------------- | -------------- |
| `pk_live_…` | browsers (origin-restricted)       | ingest only    |
| `sk_live_…` | servers, scripts, anywhere private | ingest only    |
| `rk_live_…` | servers, MCP clients, dashboards   | read only      |

Add your site's origin to the project's allowlist before using `pk_*` keys from a browser.

## 2. Send your first event

### curl

```bash
curl -X POST https://api.millimetric.ai/v1/track \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "event": "signup",
    "anonymous_id": "u_abc",
    "user_id": "user_42",
    "url": "https://yoursite.com/?utm_source=facebook&utm_medium=cpc",
    "properties": { "plan": "free" }
  }'
```

### Browser — snippet (any HTML page)

```html
<script async src="https://cdn.millimetric.ai/v1/a.js"
        data-key="pk_live_…"></script>
<script>
  // optional: track a custom event
  window.mm?.track("clicked_pricing", { plan: "pro" });
</script>
```

What you get automatically:

* `$pageview` on load and every SPA route change
* `anonymous_id` in `localStorage` (no cookies)
* Captures `utm_*`, `fbclid`, `gclid`, `ttclid`, `msclkid`, `li_fat_id`, viewport, language, timezone
* Respects DNT / Global Privacy Control
* Batches events; flushes on `pagehide` via `sendBeacon`

### Browser — npm (React / Next / Vue / Svelte)

```bash
npm i @millimetric/track
```

```ts
import { init, track, identify, page } from "@millimetric/track";

init({ key: "pk_live_…" });
track("signup", { plan: "free" });
identify(user.id, { email: user.email });
```

### Node / Bun

```bash
npm i @millimetric/track-node
```

```ts
import { init, track, flush } from "@millimetric/track-node";

init({ key: "sk_live_…", host: "https://api.millimetric.ai" });
track({ event: "purchase", anonymous_id: req.cookies.aid, user_id: user.id,
        properties: { amount_cents: 4900 } });
// at the end of a serverless handler:
await flush();
```

### AI agent via MCP

Point any MCP-speaking agent at `https://api.millimetric.ai/mcp` with a Bearer token. **Only server-side keys** are accepted — `pk_*` is rejected because it's designed to ship in browser JS. Tools available:

* `track_event` (requires an `sk_*` or `admin` key)
* `query_events`, `get_stats`, `top_sources` (`rk_*`)

Example tool call:

```json
{ "name": "top_sources",
  "arguments": { "from": "2026-05-01T00:00:00Z", "to": "2026-05-16T00:00:00Z",
                 "breakdown": "source_medium" } }
```

## 3. Query your data

```bash
# Source/medium breakdown (this is what makes FB social vs paid show up)
curl "https://api.millimetric.ai/v1/sources?from=2026-05-01&to=2026-05-16&breakdown=source_medium" \
  -H "Authorization: Bearer rk_live_…"

# Aggregate stats
curl "https://api.millimetric.ai/v1/stats?metric=count&from=2026-05-01&to=2026-05-16&event=signup&group_by=source,medium&interval=day" \
  -H "Authorization: Bearer rk_live_…"

# Raw events
curl "https://api.millimetric.ai/v1/query?from=2026-05-01&to=2026-05-16&event=signup&limit=100" \
  -H "Authorization: Bearer rk_live_…"
```

## 4. Privacy operations

```bash
# Forget every event for a user (GDPR delete)
curl -X POST https://api.millimetric.ai/v1/forget \
  -H "Authorization: Bearer sk_live_…" \
  -d '{"user_id":"user_42"}'

# Bind an anonymous id to a user id
curl -X POST https://api.millimetric.ai/v1/identify \
  -H "Authorization: Bearer sk_live_…" \
  -d '{"anonymous_id":"u_abc","user_id":"user_42","traits":{"plan":"pro"}}'
```

## How attribution is classified

Every event runs through a server-side classifier that turns raw `utm_*`, click IDs, and referrer into a clean `(source, medium, confidence)` tuple. The rules cascade, first match wins:

1. `gclid`/`msclkid`/`ttclid`/`li_fat_id` ⇒ network / paid / **high**.
2. `fbclid` arriving via `l.facebook.com` or `lm.facebook.com` ⇒ facebook / paid / **high** (Meta's ad-redirect hosts).
3. `fbclid` + `utm_source=facebook|instagram|meta` ⇒ facebook|instagram / paid / **high**.
4. `utm_medium=cpc|paid|paid_social|cpm|display|retargeting` ⇒ paid / **high**.
5. `fbclid` alone, no other context ⇒ facebook / paid / **medium** (`fbclid` can leak onto organic shares but the dominant case is ad clicks).
6. Explicit `utm_source` with no paid signal ⇒ source / utm\_medium / **high**.
7. Referrer ∈ Facebook hosts without `fbclid` ⇒ facebook / **social** / medium.
8. Other known social referrers (twitter, linkedin, reddit, tiktok, youtube, pinterest, …) ⇒ source / social / medium.
9. Search engines (google.\*, bing, duckduckgo, …) ⇒ source / organic / medium.
10. Email clients / ESPs ⇒ email / email / medium.
11. Same-host referrer ⇒ internal / direct / high.
12. No referrer & no UTM & no click ID ⇒ direct / direct / high.
13. Otherwise ⇒ slug of referrer host / referral / **low**.

The matching rule's id is stored on every event so you can audit or re-classify later.


# Architecture

```
clients (curl / browser SDK / Node SDK / MCP-speaking AI agent)
        │  Bearer <api-key>
        ▼
┌────────────────────────────────────────────────┐
│  Hono on Cloudflare Workers                    │
│  /v1/track    /v1/batch    /v1/identify        │
│  /v1/forget   /v1/query    /v1/stats           │
│  /v1/sources  /mcp         /internal/retention │
└──────┬─────────────────────────┬───────────────┘
       │ control plane           │ event plane
       ▼                         ▼
┌──────────────────┐    ┌────────────────────────┐
│ Supabase         │    │ ClickHouse Cloud       │
│ • auth.users     │    │ • events               │
│ • projects       │    │ • daily_rollup MV      │
│ • api_keys       │    │ • sessions MV          │
│ • usage_meter    │    │                        │
└──────────────────┘    └────────────────────────┘
```

## Why this split

* **Supabase (Postgres)** holds the control plane: users, projects, API keys, billing meter. Row-level security and auth come for free. Updated mostly on UI actions, low write volume.
* **ClickHouse** holds the event plane: append-only, column-store, parameterised SELECTs over partitioned data. Built for the analytics workload.

The API never lets ClickHouse see the user's API key directly — every query is parameterised on `project_id`, which the API derived from the key. That's the only multi-tenant boundary the query plane needs.

## Request shape

1. Client sends `Authorization: Bearer {kind}_{env}_{prefix}_{secret}`.
2. Worker parses the key, looks up the prefix in Supabase (cached 5 min in-process), and constant-time compares `sha256(secret + pepper)` against the stored hash.
3. Worker applies scope (`ingest` / `read` / `admin`) and — for `pk_` keys — origin allowlist.
4. For ingest, the classifier runs server-side over `(url, referrer, request_host)` and the row goes into ClickHouse via the HTTP interface as `JSONEachRow`.
5. For read, the route or MCP tool builds a parameterised SELECT through `services/events.ts` and returns JSON.

## Privacy invariants

* Raw IP is never persisted. IPs are HMAC'd with a daily-rotating salt; only the country is kept.
* No cookies. The caller supplies `anonymous_id`; the browser SDK stores it in `localStorage`.
* `properties` payload is capped at 8 KB (oversized blobs are replaced with a stub).
* Per-project `retention_days` is enforced by `/internal/retention/run`, called on a Cron Trigger schedule, which issues per-project `ALTER TABLE … DELETE`.
* `/v1/forget` issues an immediate parameterised delete for `(project_id, user_id)` — `sk_*` keys only.

## File map

| Path                              | What lives there                                         |
| --------------------------------- | -------------------------------------------------------- |
| `apps/api/src/index.ts`           | App entry; routes wired into Hono.                       |
| `apps/api/src/routes/`            | One file per REST endpoint.                              |
| `apps/api/src/mcp/server.ts`      | JSON-RPC MCP handler (tools + resources).                |
| `apps/api/src/auth/apiKey.ts`     | Bearer middleware, key parsing, origin allowlist.        |
| `apps/api/src/auth/rateLimit.ts`  | In-memory token bucket per project + route.              |
| `apps/api/src/services/events.ts` | Shared SQL helpers used by REST + MCP.                   |
| `apps/api/src/clickhouse/`        | HTTP client (insert + parameterised select).             |
| `apps/api/src/supabase/`          | Minimal PostgREST client.                                |
| `apps/api/src/util/buildRow.ts`   | Validated input → `EventRow`. Runs classifier + IP hash. |
| `packages/classifier/`            | Pure source/medium classifier (no deps).                 |
| `packages/schema/`                | Zod schemas shared by API + SDKs.                        |
| `packages/sdk-browser/`           | `@millimetric/track` — npm entry + CDN snippet.          |
| `packages/sdk-node/`              | `@millimetric/track-node` — server-side wrapper.         |
| `infra/supabase/migrations/`      | Control-plane DDL + RLS.                                 |
| `infra/clickhouse/migrations/`    | Events table + materialised views.                       |

## Operational notes

* **Cron**: schedule `/internal/retention/run` daily with `X-Internal-Secret: $API_KEY_PEPPER`.
* **Rate limiting**: in-memory token bucket today (per-Worker-instance). Move to a Durable Object if free-tier abuse becomes real.
* **Auth cache**: Workers cache key lookups for 5 minutes; the worst-case latency after a key rotation is bounded by that TTL.
* **Bundle**: the Worker imports `hono`, `zod`, and the local classifier — no MCP SDK in the bundle, which keeps cold-start small.


# Events

What an event is, how to send one, and what the server adds.

An **event** is a single thing that happened, attributed to one visitor at one point in time. Everything in Millimetric is built on events — page views, signups, purchases, AI agent steps, server-side mutations.

If you can describe it as a verb in past tense — *signed up*, *clicked*, *checked out*, *retried* — it's an event.

## The minimum viable event

```json
{ "event": "signup" }
```

That's it. Send that to `POST /v1/track` with a Bearer key and you have a working analytics setup. Everything else is optional context that makes the data more useful.

## The full shape

```json
{
  "event": "purchase",
  "event_id": "evt_8f3c1...",
  "timestamp": "2026-05-16T19:00:00.000Z",

  "anonymous_id": "u_abc",
  "user_id": "user_42",
  "session_id": "sess_xyz",

  "url": "https://yoursite.com/checkout/?utm_source=facebook&utm_medium=cpc",
  "path": "/checkout/",
  "referrer": "https://l.facebook.com/",

  "properties": {
    "amount_cents": 4900,
    "currency": "usd",
    "items": 2,
    "plan": "pro"
  }
}
```

| Field          | Required | What it does                                                                                |
| -------------- | -------- | ------------------------------------------------------------------------------------------- |
| `event`        | yes      | Event name. 1–128 chars. Use snake\_case.                                                   |
| `event_id`     | no       | Idempotency / join key. 1–128 chars.                                                        |
| `timestamp`    | no       | ISO 8601. Defaults to server clock at ingest time.                                          |
| `anonymous_id` | no       | Caller-supplied UUID. Server fabricates one if you don't send it.                           |
| `user_id`      | no       | Your stable user id, once known.                                                            |
| `session_id`   | no       | Custom session boundary. Defaults to `${anonymous_id}-${30min_bucket}`.                     |
| `url`          | no       | Landing URL **with** query string — the classifier reads `utm_*`, `fbclid`, etc. from here. |
| `path`         | no       | Path portion. Browser SDK fills this.                                                       |
| `referrer`     | no       | `document.referrer` or server-side `Referer` header. The classifier reads this too.         |
| `properties`   | no       | Free-form JSON. ≤ 8 KB after stringify.                                                     |

The full Zod schema lives in [`packages/schema/src/index.ts`](/reference/event-schema).

## What the server adds

Every event is enriched server-side before it lands in ClickHouse. **You don't need to send any of this** — if you do, it gets ignored.

```json
{
  "source": "facebook",
  "medium": "paid",
  "campaign": "spring_launch",
  "source_confidence": "high",
  "source_rule_id": "fb_ad_redirect",

  "country": "GB",
  "device_type": "desktop",
  "browser": "chrome",
  "os": "macos",

  "ip_hash": "a1b2c3d4...",
  "session_id": "u_abc-2026051619"
}
```

* `source / medium / campaign` come from the [classifier](/core-concepts/attribution), which reads `url` + `referrer`.
* `country / device_type / browser / os` come from geo-IP and the User-Agent header.
* `ip_hash` is `HMAC(ip, IP_SALT || UTC_date)` — un-reversible and rotates daily. Raw IPs are never persisted.
* `session_id` is filled in if you didn't send one.

## Event types you'll meet

| Convention                         | Examples                                                    | Who emits it                        |
| ---------------------------------- | ----------------------------------------------------------- | ----------------------------------- |
| **System events** (start with `$`) | `$pageview`, `$identify`                                    | Browser SDK + server, automatically |
| **Product events** (your verbs)    | `signup`, `purchase`, `clicked_pricing`, `feature_used`     | Your code                           |
| **Agent events** (AI workflows)    | `agent_task_started`, `agent_task_completed`, `tool_called` | AI agents via MCP                   |

There's no schema enforcement — pick a naming convention (see [Event & property naming](/reference/event-naming)) and stick to it.

## Sending an event

### Browser

```ts
import { track } from "@millimetric/track";

track("signup", { plan: "free", invited: true });
```

### Server (Node, Bun, Deno, edge)

```ts
import { track } from "@millimetric/track-node";

track({
  event: "purchase",
  anonymous_id: req.cookies.aid,
  user_id: user.id,
  properties: { amount_cents: 4900, currency: "usd" }
});
```

### Anywhere — plain HTTP

```bash
curl -X POST https://api.millimetric.ai/v1/track \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "support_ticket_opened",
    "user_id": "user_42",
    "properties": { "category": "billing", "priority": "high" }
  }'
```

### From an AI agent (MCP)

```json
{
  "name": "track_event",
  "arguments": {
    "event": "agent_task_completed",
    "anonymous_id": "agent_001",
    "properties": { "task": "refactor_billing", "duration_ms": 12400 }
  }
}
```

All four shapes hit the same endpoint and produce the same row.

## Sending many events at once

For backfills and buffered SDK flushes, use [`POST /v1/batch`](/api-reference/batch). Up to 1000 events per call, all-or-nothing at the request level.

```bash
curl -X POST https://api.millimetric.ai/v1/batch \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "event": "$pageview", "anonymous_id": "u_a", "url": "https://yoursite.com/" },
      { "event": "clicked_cta", "anonymous_id": "u_a", "properties": { "label": "hero" } },
      { "event": "signup", "anonymous_id": "u_a", "user_id": "user_1" }
    ]
  }'
```

## Idempotency

Pass `event_id` if *you* want a stable identifier — for joining with rows in your own database, or to safely replay a backfill. Right now the server does **not** dedupe on `event_id` (that's on the roadmap), so re-sending the same id will insert two rows. Use it as a join key, not a deduper.

```json
{ "event": "purchase", "event_id": "order_8f3c1", "user_id": "user_42",
  "properties": { "amount_cents": 4900 } }
```

## What happens when an event lands

1. **Auth** — Bearer token resolved to a project + scope. `pk_*` keys also have their `Origin` checked.
2. **Validation** — Zod parse against [`trackEventInput`](/reference/event-schema). Bad payloads return `400 invalid_payload`.
3. **Rate limit** — token bucket per project per route (50/sec for `track`, 5/sec for `batch`).
4. **Enrichment** — classifier runs, IP is hashed, UA is parsed, `session_id` is derived if absent.
5. **Insert** — single-row `JSONEachRow` into ClickHouse via the HTTP interface.
6. **Materialised views** — `daily_rollup` and `sessions` update within seconds.

The whole hot path is \~4–8 ms p50 on a Cloudflare Worker.

## What happens when an event *doesn't* land

| Symptom                          | Cause                                                   | Fix                                                                            |
| -------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `400 invalid_payload`            | `event` missing, or `properties` > 8 KB.                | Trim `properties`. Check `details` in the response.                            |
| `403 origin_not_allowed`         | `pk_*` key, but `Origin` header isn't in the allowlist. | Add the origin in the dashboard, or use `sk_*` server-side.                    |
| `403 forget_requires_secret_key` | You hit `/v1/forget` with a `pk_*` key.                 | Use `sk_*`.                                                                    |
| `429 rate_limited`               | Burst over 200 `track`s/project/sec.                    | Back off — `Retry-After` is in the response header.                            |
| Browser SDK silently no-ops      | DNT or GPC is on.                                       | Override with `init({ ignoreOptOut: true })` only with a separate legal basis. |

## Where events go after they land

* **Raw events** — `events` table in ClickHouse. Queried by [`/v1/query`](/api-reference/query).
* **Daily aggregates** — `daily_rollup` materialised view. Queried by [`/v1/stats`](/api-reference/stats).
* **Sessions** — `sessions` materialised view. Used for entry-source attribution and the FB social-vs-paid split in [`/v1/sources`](/api-reference/sources).

## See also

* [Properties](/core-concepts/properties) — what to put in `properties` and what the SDK adds for you.
* [Identities](/core-concepts/identities) — `anonymous_id` vs `user_id`, when to call `/v1/identify`.
* [Sessions](/core-concepts/sessions) — how `session_id` is derived and when to override it.
* [Attribution](/core-concepts/attribution) — the classifier in detail.
* [Event & property naming](/reference/event-naming) — conventions that scale.


# Properties

What to put in \`properties\`, what the SDK adds automatically, and how to design a clean schema.

`properties` is a free-form JSON object you attach to any event. It's where the *interesting* part of an event lives — the plan, the amount, the button label, the experiment variant.

```json
{
  "event": "purchase",
  "properties": {
    "amount_cents": 4900,
    "currency": "usd",
    "items": 2,
    "plan": "pro",
    "discount_code": "SPRING25"
  }
}
```

Limits:

| Limit                             | Value                                                                             |
| --------------------------------- | --------------------------------------------------------------------------------- |
| Total size after `JSON.stringify` | **8 KB**                                                                          |
| Property names                    | strings, no length cap, but be reasonable                                         |
| Property values                   | any JSON-serialisable value — string, number, boolean, null, array, nested object |
| Cardinality                       | no server-side cap (your problem if you put `request_id` on every event)          |

If `properties` exceeds 8 KB, the value is **replaced** by `{"__truncated":true,"original_bytes":N}` rather than partially committed. We never ship a malformed payload.

## What you put there

Anything specific to *this* event that you want to slice on later.

```ts
track("video_started", {
  video_id: "v_42",
  duration_s: 320,
  quality: "1080p",
  player: "native",
  is_autoplay: false
});

track("checkout_completed", {
  amount_cents: 4900,
  currency: "usd",
  item_count: 2,
  payment_method: "card",
  is_first_purchase: true,
  experiment_variant: "control"
});

track("error_shown", {
  error_code: "RATE_LIMITED",
  http_status: 429,
  retry_count: 2,
  endpoint: "/v1/track"
});
```

Rules of thumb:

* **One value per key.** Don't pack JSON into a single string property — the query layer can't index strings as objects.
* **Numbers as numbers, booleans as booleans.** `amount_cents: 4900`, not `"4900"`. `is_first_purchase: true`, not `"true"`.
* **Money in cents.** Or a fixed-precision integer. Float arithmetic on revenue is a path to bug reports.
* **Don't double-encode.** ClickHouse stores `properties` as a `String`; the API parses your object once on insert and re-serialises it. Sending `properties: "{\"plan\":\"pro\"}"` makes querying painful.

## What you don't put there

| Don't put in `properties`                               | Why                                                                     | Use instead                                    |
| ------------------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------- |
| Personally identifying info (email, full name, address) | Privacy. Once it's in events, it's hard to clean.                       | Keep PII in your own DB; key by `user_id`.     |
| Free-text user input                                    | Cardinality explosion.                                                  | Hash it, bucket it, or skip it.                |
| Raw IP, exact location                                  | We HMAC IPs and store country only — sending raw IP defeats the design. | Trust the server's `country` enrichment.       |
| Secrets / tokens                                        | They'd live forever in ClickHouse.                                      | Anything. Just not this.                       |
| Huge blobs                                              | 8 KB cap.                                                               | Store in your own object storage; pass the id. |

## What the browser SDK adds for free

On every event, [`@millimetric/track`](/sdks/browser) merges these into `properties`:

```ts
{
  utm_source, utm_medium, utm_campaign, utm_content, utm_term,
  fbclid, gclid, ttclid, msclkid, li_fat_id, dclid, gbraid, wbraid, yclid,
  $viewport_w, $viewport_h,
  $language,           // navigator.language
  $timezone            // Intl.DateTimeFormat().resolvedOptions().timeZone
}
```

Plus on the event itself (not in `properties`):

```ts
{ url, path, referrer, anonymous_id, user_id, timestamp }
```

The classifier reads `url` + `referrer` to produce `source / medium / campaign / source_confidence / source_rule_id` server-side — see [Attribution](/core-concepts/attribution).

You can override anything by passing it explicitly:

```ts
track("custom_pageview", {
  utm_source: "newsletter",     // overrides the URL-derived one
  utm_medium: "email"
});
```

## What the Node SDK adds

Almost nothing. The Node SDK is a thin wrapper over the HTTP API — it doesn't infer browser-only context (no UA, no language, no viewport). You send what you mean.

What you *do* get for free, server-side:

* `country` from the request IP via Cloudflare geo headers.
* `device_type / browser / os` from the User-Agent header — though for backend events that's normally "the request that triggered this server-side `track`", which may not match the visitor's actual browser. Pass `url` + `referrer` from your request handler if you want the classifier to fire correctly.

## Naming conventions

Pick one and stick to it. We recommend:

* **snake\_case** for property names: `amount_cents`, `is_first_purchase`, `plan_tier`.
* **`$`-prefix for system properties** the SDK or server adds: `$viewport_w`, `$language`, `$timezone`.
* **Units in the name** when ambiguous: `duration_ms`, `amount_cents`, `size_bytes`, `latency_ms`.
* **Booleans named as questions**: `is_paid`, `has_premium`, `was_invited`.
* **Currency always alongside amount**: `amount_cents` + `currency: "usd"`.

Full conventions in [Event & property naming](/reference/event-naming).

## Property semantics that downstream tools rely on

Some properties are special because the API or MCP tools index on them.

| Property                                 | Where it surfaces                                                | When to set it                                                          |
| ---------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `utm_source / utm_medium / utm_campaign` | Drives `source / medium / campaign` if `url` doesn't carry them. | Pass-through from server-side handlers that already parsed the URL.     |
| `$pageview` (event name, not property)   | Drives `path`, page-view counts, top-paths breakdown.            | Browser SDK handles automatically; call `page()` for virtual pageviews. |
| `amount_cents` + `currency`              | Convention for the upcoming revenue dashboards.                  | On any conversion event with monetary value.                            |
| `experiment_id` + `variant`              | Convention for A/B test analysis.                                | On every event that occurred under an active experiment.                |

None of these are *required* — but if you set them with these names, the dashboards and the MCP `top_sources` / `get_stats` tools will Just Work.

## Querying on properties

`properties` is a `String` in ClickHouse holding JSON. To filter or aggregate on a property, use ClickHouse's JSON functions:

```sql
SELECT
  JSONExtractString(properties, 'plan') AS plan,
  count()
FROM events
WHERE project_id = '...'
  AND event_name = 'signup'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY plan
ORDER BY count() DESC;
```

For numeric extraction: `JSONExtractInt`, `JSONExtractFloat`. For booleans: `JSONExtractBool`.

Right now [`/v1/query`](/api-reference/query) returns `properties` as a JSON string for the client to parse. [`/v1/stats`](/api-reference/stats) doesn't yet support `group_by` on a property key — that's on the roadmap. For now, run targeted SQL via your ClickHouse credentials if you need it.

## Examples by event class

### Page view

```ts
page("/pricing", {
  variant: "v2",
  is_logged_in: false
});
```

### Conversion

```ts
track("trial_started", {
  plan: "pro",
  trial_days: 14,
  came_from: "pricing_hero"
});
```

### Engagement

```ts
track("doc_searched", {
  query_length: 12,
  results_count: 5,
  selected_index: 0
});
```

### Error

```ts
track("payment_failed", {
  error_code: "card_declined",
  attempt: 2,
  amount_cents: 4900,
  currency: "usd"
});
```

### Agent step (MCP)

```json
{
  "name": "track_event",
  "arguments": {
    "event": "tool_called",
    "anonymous_id": "agent_001",
    "user_id": "user_42",
    "properties": {
      "tool": "search_repo",
      "input_tokens": 412,
      "output_tokens": 87,
      "latency_ms": 1240,
      "model": "claude-4.6-sonnet"
    }
  }
}
```

## See also

* [Events](/core-concepts/events) — the surrounding event shape.
* [Event & property naming](/reference/event-naming) — conventions in one page.
* [Privacy](/core-concepts/privacy) — what we *never* store, and why.
* [POST /v1/track](/api-reference/track) — the endpoint.


# Identities

anonymous\_id, user\_id, and how /v1/identify stitches them together.

Millimetric uses two identifiers for "who did this", and they're independent on purpose:

| Identifier     | Lifecycle                           | Lives in                                                    | Set by                                    |
| -------------- | ----------------------------------- | ----------------------------------------------------------- | ----------------------------------------- |
| `anonymous_id` | Per device / browser, persistent    | `localStorage` (browser) or your own cookie/header (server) | Caller — the SDK generates it client-side |
| `user_id`      | Per *person*, stable across devices | Your auth system                                            | You — once a visitor authenticates        |

Every event must be attributable to **at least one of them**. Both is great. Neither isn't allowed (the server will fabricate an `anonymous_id` if you really don't send one — but you've thrown away your ability to re-link the visit).

## anonymous\_id

A pseudonymous, per-device UUID. The browser SDK creates one on first load and stores it under `localStorage["mm_aid"]`. It survives refreshes, tab closes, and SPA navigations. It does **not** survive:

* Clearing browser storage.
* Switching browsers.
* Switching devices.
* Private / incognito sessions (the SDK falls back to an in-memory UUID for the tab's lifetime).

That's a feature, not a bug — `anonymous_id` is meant to be *low-stakes*. If a visitor wipes their data, the link is severed.

```ts
import { getAnonymousId, setAnonymousId } from "@millimetric/track";

getAnonymousId();              // "u_8f3c1a..."
setAnonymousId("u_custom_42"); // override (e.g. from server-rendered HTML)
```

Server-side, you supply your own anonymous id — usually from a cookie you set yourself or a request header you forward from the browser:

```ts
track({
  event: "form_submit",
  anonymous_id: req.cookies.aid,    // ← your job to pass this through
  user_id: user.id,
  properties: { form: "contact" }
});
```

## user\_id

Your stable, internal user id. Same one you use in your own database. Once known, you set it on every subsequent `track`.

The browser SDK stores `user_id` after `identify()` and merges it into every event:

```ts
import { identify, track } from "@millimetric/track";

identify(user.id, { email: user.email, plan: user.plan });
track("clicked_pricing");   // automatically tagged with user_id
```

The Node SDK doesn't have local state — pass `user_id` explicitly on each call.

## /v1/identify — stitching them together

Calling [`POST /v1/identify`](/api-reference/identify) emits a special `$identify` event that links a known `user_id` to the visitor's `anonymous_id`:

```bash
curl -X POST https://api.millimetric.ai/v1/identify \
  -H "Authorization: Bearer $SK_KEY" \
  -d '{
    "anonymous_id": "u_abc",
    "user_id": "user_42",
    "traits": { "email": "matt@example.com", "plan": "pro" }
  }'
```

What this changes:

* A `$identify` event lands in ClickHouse with `event_name='$identify'`, `anonymous_id`, `user_id`, and `traits` in `properties`.
* Subsequent events for that `anonymous_id` should also send `user_id` (the SDK does this automatically).
* **Historical events for the same `anonymous_id` are not retroactively rewritten.** They still have `user_id = NULL`. To stitch them in analysis, JOIN through the `$identify` event or use the `sessions` view.

## When to call identify

| Moment                      | Call identify?                                                           |
| --------------------------- | ------------------------------------------------------------------------ |
| User signs up               | **yes** — you finally know who they are                                  |
| User logs in (returning)    | **yes** — confirm the link on this device                                |
| User logs out               | **no** — keep tracking events anonymously, do not flip `user_id` to null |
| User switches account       | **yes** — call identify with the new `user_id`                           |
| Visitor browses anonymously | **no** — no need until they identify themselves                          |

Don't be afraid to call `identify` more than once. It's idempotent at the event level — each call just emits another `$identify`. Some teams call it on every authenticated request handler so every device the user touches gets a fresh stitch.

## The pre-/post-login stitch

The killer use case. A visitor lands from a Facebook ad, browses for a week, finally signs up.

```
Day 1, 14:00  $pageview        anonymous_id=u_abc, user_id=NULL
                                 source=facebook, medium=paid
Day 1, 14:02  clicked_pricing  anonymous_id=u_abc, user_id=NULL
Day 5, 09:30  $pageview        anonymous_id=u_abc, user_id=NULL
Day 7, 11:14  $identify        anonymous_id=u_abc, user_id=user_42
                                 (← here you call /v1/identify)
Day 7, 11:14  signup           anonymous_id=u_abc, user_id=user_42
```

For revenue attribution, you want to credit the Day 1 paid Facebook click for the Day 7 signup. Two approaches:

**1. Per-session attribution (recommended).** The `sessions` materialised view captures `entry_source / entry_medium` per `(anonymous_id, 30-min window)`. `user_id` shows up on the `$identify` event itself. JOIN sessions to identifies to credit the *first* session that brought this user in.

**2. Per-user attribution.** Find the earliest event for `anonymous_id = u_abc` regardless of `user_id`:

```sql
SELECT
  user_id,
  argMin(source, timestamp) AS first_touch_source,
  argMin(medium, timestamp) AS first_touch_medium
FROM events
WHERE project_id = '...'
  AND anonymous_id IN (
    SELECT DISTINCT anonymous_id
    FROM events
    WHERE user_id = 'user_42' AND project_id = '...'
  )
GROUP BY user_id;
```

(See the [Anonymous → known](/recipes/anonymous-to-known) recipe for a fleshed-out version.)

## traits

`traits` on `/v1/identify` are stored in the `$identify` event's `properties`. They're *the user's attributes at the moment they identified* — not a profile that lives elsewhere. There's no "user store"; if you need the latest plan, query your own DB.

```json
{ "anonymous_id": "u_abc", "user_id": "user_42",
  "traits": { "plan": "pro", "team_size": 4, "signup_at": "2026-05-16T11:14:00Z" } }
```

Best practice: include just enough on `traits` to enable cohort filtering in event queries (`plan`, `tier`, `team_size`). For the source of truth, query your own DB.

## A device with multiple users

The SDK stores **one** `user_id` at a time. If user A logs out and user B logs in on the same browser:

```ts
// user A logs out — your code's choice
// (recommended: leave the user_id stale; subsequent tracks until B identifies will look like A)

identify(userB.id);   // overwrites the stored user_id; emits $identify for B
```

Anonymous events between the logout and the next identify will be tagged with whichever `user_id` was last set. If that bothers you, call `setAnonymousId(crypto.randomUUID())` between users to force a fresh anonymous identity.

## A user across multiple devices

Each device has its own `anonymous_id`. As soon as the user identifies on each device, you'll have:

* `user_42` ↔ `u_abc` (laptop)
* `user_42` ↔ `u_xyz` (phone)

The events on both devices share `user_id = user_42`. Per-user analytics (`group by user_id`) works seamlessly. Per-device analytics (`group by anonymous_id`) splits them, which is correct.

## Forgetting

[`POST /v1/forget`](/api-reference/forget) deletes events by `(project_id, user_id)`. It does **not** touch events that were emitted before identify (`user_id = NULL`). Those are indistinguishable from any other anonymous traffic — by design.

If you need to forget by `anonymous_id`, run the SQL directly:

```sql
ALTER TABLE events DELETE
  WHERE project_id = '{project_id}'
    AND anonymous_id = '{anonymous_id}';
```

## See also

* [POST /v1/identify](/api-reference/identify) — the endpoint.
* [Sessions](/core-concepts/sessions) — how anonymous activity gets bucketed.
* [Anonymous → known users recipe](/recipes/anonymous-to-known).
* [GDPR delete recipe](/recipes/gdpr-delete).


# Sessions

How session\_id is derived, when to override it, and what the sessions view does for you.

A **session** is a span of activity from a single visitor with no more than 30 minutes of idle time between events. Sessions exist mainly to answer one question well: *what brought this person in?*

In Millimetric, sessions are a **derived** concept. The raw `events` table has a `session_id` per row; the `sessions` materialised view aggregates them.

## How session\_id is derived

If you don't send `session_id`, the server fills it in:

```
session_id = `${anonymous_id}-${30min_bucket}`

30min_bucket = floor(unix_seconds / 1800)
```

So a single visitor's events get the same `session_id` as long as they happen inside the same 30-minute window. As soon as the gap exceeds \~30 minutes, the bucket increments and the visitor is in a new session.

This is intentionally simple:

* **Stateless.** No server-side timer per visitor; sessions fall out of timestamps + anonymous id.
* **Deterministic.** The same input produces the same `session_id` whether it's processed live or replayed from a backfill.
* **Cheap.** The classifier and ingest path don't read or write session state.

The trade-off: edges of the bucket can split or merge sessions slightly differently than a "30 min since last event" sliding window. For the analytics queries Millimetric optimises for, the difference is rounding noise.

## Overriding session\_id

You should override only when you need session boundaries to mean something specific in your product. For example:

* **A long-form video player** where you want a "watch session" to span hours.
* **A multi-step onboarding** that you want grouped even across reloads.
* **An AI agent** where each `task_id` is a session for telemetry purposes.

```ts
import { track } from "@millimetric/track-node";

track({
  event: "tool_called",
  anonymous_id: "agent_001",
  user_id: "user_42",
  session_id: `task_${task.id}`,    // group every event in this task
  properties: { tool: "search_repo" }
});
```

The browser SDK doesn't expose a `setSessionId` — the auto-derived one is what you want for web. Override only via the HTTP layer.

## The sessions materialised view

`sessions` is a ClickHouse `AggregatingMergeTree` view that groups events by `(project_id, session_id, anonymous_id)` and stores:

| Column                                         | What it is                                              |
| ---------------------------------------------- | ------------------------------------------------------- |
| `session_id`                                   | derived or user-supplied                                |
| `anonymous_id`                                 | the visitor                                             |
| `user_id`                                      | filled in if `/v1/identify` happened during the session |
| `started_at`                                   | min(timestamp)                                          |
| `ended_at`                                     | max(timestamp)                                          |
| `duration_s`                                   | `ended_at - started_at`                                 |
| `event_count`                                  | count of all events in the session                      |
| `pageview_count`                               | count of `$pageview` events                             |
| `entry_source / entry_medium / entry_campaign` | classifier output on the **first** event of the session |
| `entry_url / entry_path / entry_referrer`      | from the first event                                    |
| `country / device_type / browser / os`         | first event's enrichment                                |

### Why `entry_source` is the right answer for attribution

Per-event attribution is per-event truth: a server-side `signup` POSTed without `url` or `referrer` will classify as `direct`. That's correct *for that event*, but it's the wrong answer for "where did this customer come from".

The `entry_source` on a session is the first-touch attribution for that visit. Query it instead of raw events when you want to credit the channel that brought someone in.

```sql
SELECT
  entry_source,
  entry_medium,
  count() AS sessions,
  uniq(anonymous_id) AS visitors
FROM sessions
WHERE project_id = '...'
  AND started_at > now() - INTERVAL 7 DAY
GROUP BY entry_source, entry_medium
ORDER BY sessions DESC;
```

This is what powers [`/v1/sources`](/api-reference/sources) — the endpoint that gives you Facebook *paid* and Facebook *social* on separate rows.

## Pre-login → post-login in a session

If a visitor identifies *during* a session, the `sessions` row sees both:

* `anonymous_id = u_abc` for the whole session
* `user_id = user_42` because at least one event in the session carried it (the `$identify` itself)
* `entry_source / entry_medium` from the first event (which was anonymous)

Result: the session is correctly credited to the channel that brought them in, with the user id stitched on.

## Multiple sessions per visitor

Common. Treat them independently for attribution purposes:

```sql
-- visitor's session count and channel mix
SELECT
  anonymous_id,
  count() AS sessions,
  groupArray(entry_source) AS channels
FROM sessions
WHERE project_id = '...'
  AND started_at > now() - INTERVAL 30 DAY
GROUP BY anonymous_id
HAVING sessions > 1
ORDER BY sessions DESC
LIMIT 50;
```

Multi-touch attribution (e.g. credit each channel proportionally) is a query-layer concern — Millimetric stores raw and first-touch; you compose more sophisticated models on top.

## Sessions for the FB social-vs-paid split

Combining sessions + the classifier is the whole point.

```sql
SELECT
  entry_source,
  entry_medium,
  count() AS sessions,
  uniq(anonymous_id) AS visitors,
  countIf(event_count > 1) AS engaged_sessions
FROM sessions
WHERE project_id = '...'
  AND entry_source = 'facebook'
  AND started_at > now() - INTERVAL 30 DAY
GROUP BY entry_source, entry_medium;
```

→

```
entry_source | entry_medium | sessions | visitors | engaged_sessions
facebook     | paid         |    432   |   391    |       301
facebook     | social       |    187   |   180    |       113
```

That breakdown is the answer to the question every marketer asks and almost no analytics tool answers cleanly.

## What sessions don't do

* **No cross-device stitching.** A visitor on a phone and a laptop has two different `anonymous_id`s and therefore different sessions. Use `user_id` (post-identify) to roll those up.
* **No session-level revenue.** Attach `amount_cents` to the conversion event itself; aggregate per-session in your query.
* **No engagement scoring out of the box.** `event_count`, `pageview_count`, `duration_s` give you the raw material — score it however you like.

## See also

* [Events](/core-concepts/events) — what flows into a session.
* [Identities](/core-concepts/identities) — `anonymous_id` vs `user_id`.
* [Attribution](/core-concepts/attribution) — how `entry_source` is determined.
* [GET /v1/sources](/api-reference/sources) — the read endpoint that uses sessions under the hood.


# API keys

pk\_, sk\_, rk\_, ak\_ — when to use which.

All API access uses Bearer tokens. We ship four kinds, modelled after Stripe's `pk_/sk_` split so the right ones can safely live in a browser.

| Kind        | Example                    | Where to use it                                       | What it can do                                                                                                                                                                              |
| ----------- | -------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pk_live_…` | `pk_live_f6589a94_7xPq…`   | Browsers, public pages, snippets                      | Ingest only. **Origin-allowlisted** — requests must come from an origin in your project's `allowed_origins`. **Rejected at `/mcp`** — MCP never accepts a key that ships in browser source. |
| `sk_live_…` | `sk_live_a1b2c3d4_secret…` | Servers, cron jobs, MCP write access                  | Ingest + read. No origin check.                                                                                                                                                             |
| `rk_live_…` | `rk_live_99887766_secret…` | Server-side dashboards, **MCP clients (recommended)** | Read only — query, stats, sources, MCP read tools. Cannot ingest.                                                                                                                           |
| `ak_live_…` | `ak_live_1c4d8a90_secret…` | **Account-level MCP** — agencies, multi-product teams | Read every project this account owns through one credential. **Business tier only.** Used by `/mcp/account`. Cannot ingest.                                                                 |

## What's in a key

```
{kind}_{env}_{prefix}_{secret}
└──┬──┘ └─┬┘ └──┬───┘ └──┬───┘
 pk/sk/rk live  8-char  url-safe
                lookup  secret
```

* The **prefix** (`pk_live_f6589a94`) is the lookup key. It's safe to display in dashboards and logs.
* The **secret** is shown to you exactly once at mint time. Only `sha256(secret || pepper)` is stored — we cannot recover or re-display it. If you lose it, revoke and mint a new one.

## Origin allowlist for `pk_*` keys

Every project has a list of `allowed_origins`. `pk_*` requests must carry an `Origin:` header that matches one of them, or they're rejected with `403 origin_not_allowed`.

```http
POST /v1/track HTTP/1.1
Authorization: Bearer pk_live_…
Origin: https://yoursite.com   ← must be in the project's allowed_origins
```

If you serve from `https://yoursite.com` and `https://www.yoursite.com`, list both.

`sk_*` and `rk_*` keys ignore `Origin` because they're never meant to be in a browser.

## Minting and revoking

In the dashboard (`apps/web`):

1. Open the project.
2. Click `+ pk` / `+ sk` / `+ rk`, give it a label.
3. The secret appears in a modal **once** — copy it.
4. To revoke, click **Revoke** on the key row. It cannot be undone; existing clients will immediately get `401 invalid_api_key`.

## Scopes vs kinds

| Kind | Scope    | Notes                                                                                                    |
| ---- | -------- | -------------------------------------------------------------------------------------------------------- |
| `pk` | `ingest` | Browser-safe. Only ingest endpoints. **Never accepted by `/mcp`.**                                       |
| `sk` | `ingest` | Same powers as `pk` but no origin check. Required for `/v1/forget`. Accepted by `/mcp`.                  |
| `rk` | `read`   | Read endpoints + MCP read tools. Accepted by `/mcp` (recommended for AI agents).                         |
| `ak` | `read`   | Account-scoped. Accepted **only** by `/mcp/account`. Sees every project the account owns. Business tier. |

If you call a read endpoint with an ingest-scope key (or vice versa) you'll get `403 insufficient_scope`. If you hit `/mcp` with a `pk_` or `ak_` key you'll get `403 key_kind_not_allowed`. If you hit `/mcp/account` with anything other than `ak_`, you'll get `403 account_key_required`.

## When to use an `ak_` key

Use one if you own more than one Millimetric project and you'd otherwise mint and rotate an `rk_` key per project for the same agent. Examples:

* A consultancy / agency running analytics for many clients.
* A multi-product company with one project per app.
* Staging + production isolated as separate projects.

You configure your MCP client once, against `/mcp/account`, and the agent can call `list_projects`, query a single project by `project_id` / `project_slug`, or run cross-project tools like `compare_projects` in one round trip. See [MCP overview](/mcp-for-ai-agents/overview).

## Operational notes

* **Rotation**: revoke + re-mint. Re-issuing all keys requires changing the server pepper, which is a deliberate, planned event.
* **Last-used timestamp**: each successful auth updates `last_used_at` on the key. Stale keys are easy to spot.
* **Caching**: the Worker caches verified keys for 5 minutes per instance. Revocation takes effect within that window.


# Attribution & the classifier

How the classifier turns a URL + referrer into (source, medium, confidence).

Every event runs through a **pure, ordered classifier** that maps `(url, referrer, request_host)` into a normalised `(source, medium, campaign, confidence, rule_id)` tuple. The classifier is deterministic, has no I/O, and runs server-side on every ingest.

## The output

```json
{
  "source": "facebook",
  "medium": "paid",
  "campaign": "spring_launch",
  "source_confidence": "high",
  "source_rule_id": "fb_ad_redirect"
}
```

* `source` — the network or origin (`facebook`, `google`, `twitter`, `direct`, `internal`, etc.).
* `medium` — how the visit was driven (`paid`, `organic`, `social`, `email`, `referral`, `direct`).
* `confidence` — `high` / `medium` / `low`. A signal for downstream filters.
* `rule_id` — the exact rule that fired. Stored on the row so you can audit historical data and re-classify if the rules change.

## The rule cascade

First match wins. The full table:

| #  | Rule fires when…                                                                              | Result                                     | Confidence   |
| -- | --------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------ |
| 1  | `gclid` in URL                                                                                | `google` / `paid`                          | high         |
| 2  | `msclkid`                                                                                     | `bing` / `paid`                            | high         |
| 3  | `ttclid`                                                                                      | `tiktok` / `paid`                          | high         |
| 4  | `li_fat_id`                                                                                   | `linkedin` / `paid`                        | high         |
| 5  | `fbclid` + referrer host is `l.facebook.com` / `lm.facebook.com`                              | `facebook` / `paid`                        | high         |
| 6  | `fbclid` + `utm_source` \~ facebook/instagram/meta                                            | `facebook` or `instagram` / `paid`         | high         |
| 7  | \`utm\_medium=cpc                                                                             | paid                                       | paid\_social |
| 8  | `utm_medium` ∈ paid set + `utm_source` \~ instagram                                           | `instagram` / `paid`                       | high         |
| 9  | `utm_medium` ∈ paid set + any `utm_source`                                                    | `<utm_source>` / `paid`                    | high         |
| 10 | `utm_medium` ∈ paid set, no `utm_source`                                                      | `unknown` / `paid`                         | medium       |
| 11 | `fbclid` alone, no other context                                                              | `facebook` / `paid`                        | **medium**   |
| 12 | `utm_source` explicit, no paid signal                                                         | `<utm_source>` / `<utm_medium ?? unknown>` | high         |
| 13 | Referrer host ∈ {facebook.com, m.facebook.com, l.facebook.com, …} without `fbclid`            | `facebook` / `social`                      | medium       |
| 14 | Referrer host ∈ {instagram.com, l.instagram.com}                                              | `instagram` / `social`                     | medium       |
| 15 | Referrer is a known social network (twitter, x, linkedin, reddit, tiktok, youtube, pinterest) | `<network>` / `social`                     | medium       |
| 16 | Referrer is a known search engine (google.\*, bing.\*, duckduckgo, …)                         | `<engine>` / `organic`                     | medium       |
| 17 | Referrer is an email-client / ESP                                                             | `email` / `email`                          | medium       |
| 18 | Referrer host == page host                                                                    | `internal` / `direct`                      | high         |
| 19 | No referrer + no UTM + no click ID                                                            | `direct` / `direct`                        | high         |
| 20 | Anything else                                                                                 | `<host slug>` / `referral`                 | **low**      |

## The Facebook social-vs-paid resolution (the headline)

This is the case we obsess over because it's where most tools blur the line:

| You see                                     | Most analytics show | Millimetric shows              |
| ------------------------------------------- | ------------------- | ------------------------------ |
| `?fbclid=abc` arriving via `l.facebook.com` | Facebook (mixed)    | **facebook / paid** (high)     |
| `?utm_source=facebook&utm_medium=cpc`       | Facebook            | **facebook / paid** (high)     |
| `?fbclid=abc` from anywhere else            | Facebook (mixed)    | **facebook / paid** (medium)   |
| Referrer `facebook.com`, no `fbclid`        | Facebook            | **facebook / social** (medium) |
| Referrer `m.facebook.com`, no `fbclid`      | Facebook            | **facebook / social** (medium) |

Same for `instagram.com` ↔ `utm_source=instagram&utm_medium=paid_social`.

## Why `confidence`

Some rules are inherently softer than others. `gclid` is *only* emitted by Google Ads — high confidence. `fbclid` alone *usually* means a paid click but can leak onto organic shares — medium confidence. A novel referrer we've never seen — low confidence.

In dashboards or downstream pipelines, you can filter on `source_confidence` to exclude noisy attribution when you want clean revenue-attribution numbers.

## Why `rule_id`

Stored on every event row. If the rules change in v2 and you want to re-attribute historical data, you can identify exactly which rule fired on each row and apply targeted updates instead of re-classifying everything.

## What it does *not* do

* **No fingerprinting.** No IP-based linkage, no canvas, no fonts. Just `(url, referrer)`.
* **No probabilistic attribution.** The classifier is rule-based and deterministic.
* **No machine learning.** Rule data lives in `packages/classifier/src/rules.ts` and is unit-tested against \~31 scenarios in `classify.test.ts`.

## Per-event vs. per-session attribution

The classifier evaluates *each event individually*. So a backend `signup` event POSTed without `url`/`referrer` will classify as `direct` — that's per-event truth.

For "where did this visitor enter from", query the **`sessions_v`** view instead. It captures the first-touch source/medium per `(anonymous_id, 30-min window)` and is the right answer for revenue attribution.

```sql
SELECT entry_source, entry_medium, count() AS sessions
FROM sessions_v
GROUP BY entry_source, entry_medium
ORDER BY sessions DESC;
```


# Privacy & data retention

Exactly what gets stored, and what doesn't.

Millimetric is designed so that the data you don't intend to collect can't accidentally be collected. The defaults are restrictive; you opt in to anything beyond them.

## What we *never* store

* **Raw IP addresses.** IPs are HMAC'd with a salt that rotates daily; only the 16-byte hex of the hash is kept. The salt makes the hash effectively unique-per-day, so you can dedupe visitors within a day but cannot link them across days.
* **Cookies set by us.** None. Ever. Anonymous IDs are generated client-side by the SDK and stored in `localStorage`, only.
* **Fingerprints.** No canvas, fonts, WebGL probing, screen-fingerprint hashes. The browser SDK captures viewport, language, and timezone — none of which are persistent identifiers on their own.
* **Cross-site tracking.** Every project is fully isolated. There is no shared identity across projects.

## What gets stored on each event

```
project_id          (your project)
timestamp           (when the event happened)
event_name          (e.g. "signup", "$pageview")
anonymous_id        (caller-supplied UUID)
user_id             (optional — only if you sent it)
session_id          (auto-derived from anonymous_id + 30-min bucket)

source              (classifier output — see Attribution)
medium              (classifier output)
campaign            (utm_campaign, if any)
source_confidence   (low|medium|high)
source_rule_id      (which rule fired)

referrer            (Referer URL — only the originating page, not the chain)
url                 (the landing URL, including utm_*)
path                (path portion of the URL)
country             (from geo-IP — country code only, never city)
device_type         (mobile | tablet | desktop)
browser             (chrome | firefox | safari | edge | opera | "")
os                  (windows | macos | android | ios | linux | "")
ip_hash             (HMAC(ip, IP_SALT + UTC_date) — un-reversible, rotates daily)
properties          (JSON, capped at 8 KB)
```

## Retention

Every project has a `retention_days` setting (default 90, configurable per project). The `/internal/retention/run` job — wired to a Cloudflare Cron Trigger — issues per-project `ALTER TABLE events DELETE` to enforce it.

Materialised views (`daily_rollup`, `sessions`) retain only aggregated state without `properties` or `ip_hash`, so historical breakdowns remain available for analysis after raw events expire.

## Right-to-be-forgotten

```http
POST /v1/forget
Authorization: Bearer sk_live_…
Content-Type: application/json

{ "user_id": "user_42" }
```

Issues an immediate parameterised `ALTER TABLE events DELETE WHERE project_id = ? AND user_id = ?` against ClickHouse. Mutations are async — they typically complete within seconds.

Only `sk_*` (secret) keys can call `/v1/forget`. `pk_*` keys are explicitly rejected so a leaked browser key cannot wipe data.

## Properties payload limits

| Limit                 | Value                                               |
| --------------------- | --------------------------------------------------- |
| JSON-stringified size | **8 KB**                                            |
| Cardinality (per key) | enforced upstream by your code — no server-side cap |

If `properties` exceeds 8 KB after `JSON.stringify`, the value is **replaced** by `{"__truncated":true,"original_bytes":N}` rather than being silently truncated. We never partially commit a malformed payload.

## DNT and Global Privacy Control

The **browser SDK** short-circuits the entire pipeline when either is set:

```js
navigator.doNotTrack === "1"
navigator.globalPrivacyControl === true
```

No events queued, no fetches sent, no `localStorage` writes. You can override with `init({ ignoreOptOut: true })` if you have a separate legal basis.

The server endpoints do **not** auto-apply DNT — that would be the wrong layer (servers shouldn't second-guess their own integrations). DNT enforcement lives at the SDK / client edge.

## What admins can see

* Admins (any signed-in user from the admin UI) can see their own projects, keys, and event data.
* Admins cannot see other users' projects — Supabase **RLS** restricts `projects.owner_id = auth.uid()` and cascades to `api_keys`.
* The Worker uses the **publishable** Supabase key, so even if its env were compromised, an attacker couldn't read arbitrary tables — only call the two `SECURITY DEFINER` RPCs (`verify_api_key`, `list_projects_retention`).

## Audit trail

* `api_keys.last_used_at` is updated on every successful auth.
* Every event row stores `source_rule_id` so future classifier changes can be back-traced.
* All read queries via MCP get logged with project + tool + arguments (see `apps/api/src/mcp/server.ts`).


# Overview

Base URL, auth, content type, errors, rate limits.

## Base URL

| Environment | URL                                                   |
| ----------- | ----------------------------------------------------- |
| Local dev   | `http://localhost:8787`                               |
| Production  | `https://api.millimetric.ai` (your Worker deployment) |

All endpoints are versioned under `/v1/*`.

## Authentication

```http
Authorization: Bearer {kind}_{env}_{prefix}_{secret}
```

The Worker resolves the prefix to a project + scope (see [API keys](/core-concepts/api-keys)). If the key is `pk_*`, the request must also include an `Origin:` header from the project's allowlist.

## Content type

All ingest and admin endpoints accept `application/json`. Read endpoints accept query string parameters and return JSON.

```http
Content-Type: application/json
```

## Errors

All errors are returned as JSON with a stable `error` code field:

```json
{ "error": "invalid_payload", "details": {...} }
```

| Status | `error`                      | Meaning                                                       |
| ------ | ---------------------------- | ------------------------------------------------------------- |
| 400    | `invalid_payload`            | Body failed Zod validation. `details` is the flattened error. |
| 400    | `invalid_params`             | Query string failed validation.                               |
| 400    | `invalid_group_by`           | Unknown column passed to `/v1/stats?group_by=`.               |
| 401    | `missing_bearer_token`       | No `Authorization` header.                                    |
| 401    | `malformed_api_key`          | Key doesn't match \`(pk                                       |
| 401    | `invalid_api_key`            | No matching key in the database.                              |
| 401    | `key_kind_mismatch`          | Stored key has a different kind than the prefix claims.       |
| 401    | `invalid_session`            | Admin endpoint: user JWT failed Supabase validation.          |
| 403    | `origin_not_allowed`         | `pk_*` key from an origin not in the project's allowlist.     |
| 403    | `insufficient_scope`         | Key scope doesn't cover this endpoint.                        |
| 403    | `forget_requires_secret_key` | `/v1/forget` called with a `pk_*` key.                        |
| 429    | `rate_limited`               | Token bucket exhausted. `Retry-After` header included.        |
| 500    | `internal_error`             | Unhandled server error. Worker logs have the trace.           |

## Rate limits

Per project, per route, via in-memory token bucket:

| Endpoint         | Refill rate | Burst capacity |
| ---------------- | ----------- | -------------- |
| `POST /v1/track` | 50/sec      | 200            |
| `POST /v1/batch` | 5/sec       | 20             |

Exceed → `429 rate_limited` with `Retry-After: <seconds>`.

(Buckets are per-Worker-instance today. For production-scale fairness, swap to a Durable Object — see `apps/api/src/auth/rateLimit.ts`.)

## CORS

Permissive on `/v1/*` and `/admin/*`. The actual authorization happens via `Authorization` + the project's origin allowlist for `pk_*` keys, not via a CORS allowlist.

## Idempotency

Pass an `event_id` on `/v1/track` or per-event in `/v1/batch`:

```json
{ "event": "signup", "event_id": "evt_abc123", ... }
```

If you send the same `event_id` twice the second call still inserts a row (we don't deduplicate at the database). Use this when *you* want a stable identifier for joining with your own systems — true server-side deduplication is on the roadmap.

## Versioning

`/v1/*` is stable. Breaking changes will appear under `/v2/*`. Additive changes (new fields on existing endpoints, new endpoints) happen in place.


# POST /v1/track

POST /v1/track — emit a single event.

Emit a single analytics event. The most common write endpoint.

## Auth

|                |                                                                     |
| -------------- | ------------------------------------------------------------------- |
| Required scope | `ingest`                                                            |
| Key kinds      | `pk_*` (browser, origin-checked) · `sk_*` (server, no origin check) |

## Request

```http
POST /v1/track
Authorization: Bearer {key}
Origin: {your-origin}                ← only for pk_* keys
Content-Type: application/json
```

```json
{
  "event": "signup",
  "event_id": "evt_abc123",
  "timestamp": "2026-05-16T19:00:00.000Z",
  "anonymous_id": "u_abc",
  "user_id": "user_42",
  "session_id": "sess_xyz",

  "url": "https://yoursite.com/?utm_source=facebook&utm_medium=cpc&fbclid=abc",
  "path": "/",
  "referrer": "https://l.facebook.com/",

  "properties": { "plan": "free", "team_size": 4 }
}
```

### Field reference

| Field          | Required | Notes                                                                                                                   |
| -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `event`        | **yes**  | Event name. 1–128 chars. By convention, system events start with `$` (`$pageview`, `$identify`).                        |
| `event_id`     | no       | Idempotency key, 1–128 chars.                                                                                           |
| `timestamp`    | no       | ISO 8601. Defaults to server time when the request lands.                                                               |
| `anonymous_id` | no       | Caller-supplied UUID. If omitted, the Worker generates one — but you usually want to control this from the browser SDK. |
| `user_id`      | no       | If you've called `/v1/identify`, set this on subsequent events to link them to a person.                                |
| `session_id`   | no       | If omitted, derived as `${anonymous_id}-${30min_bucket}`. Override for custom session boundaries.                       |
| `url`          | no       | Landing URL — including any `utm_*`, `fbclid`, `gclid` query params. **The classifier reads this.**                     |
| `path`         | no       | URL path. The browser SDK sets this automatically.                                                                      |
| `referrer`     | no       | `document.referrer` value (or `Referer` header server-side). **The classifier reads this.**                             |
| `properties`   | no       | Free-form JSON, capped at 8 KB after `JSON.stringify`.                                                                  |

## Response

```http
HTTP/1.1 202 Accepted
Content-Type: application/json
```

```json
{ "ok": true, "event_id": "evt_abc123" }
```

The 202 means the event has been written to ClickHouse synchronously. Materialised views (`daily_rollup`, `sessions`) update within seconds.

## What the server adds

The Worker enriches every event before insert:

* **Source classification** — `source`, `medium`, `campaign`, `source_confidence`, `source_rule_id` (see [Attribution](/core-concepts/attribution)).
* **Geo & device** — `country` (geo-IP, country code only), `device_type`, `browser`, `os` (parsed from User-Agent).
* **Hashed IP** — `ip_hash = HMAC(ip, IP_SALT || UTC_date)`. Raw IPs are never persisted.

You don't need to send any of these; if you do, they're ignored.

## Errors

See [overview](/api-reference/overview#errors). Common ones for `track`:

* `400 invalid_payload` — `event` missing or `properties` exceeds 8 KB.
* `403 origin_not_allowed` — `pk_*` key, but `Origin` isn't in the project's allowlist.
* `429 rate_limited` — burst over 200 requests; back off and retry.

## Examples

### curl (server-side)

```bash
curl -X POST https://api.millimetric.ai/v1/track \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "purchase",
    "anonymous_id": "u_abc",
    "user_id": "user_42",
    "properties": { "amount_cents": 4900, "currency": "usd" }
  }'
```

### Browser SDK

```js
import { track } from "@millimetric/track";
track("purchase", { amount_cents: 4900, currency: "usd" });
```

### Node SDK

```js
import { track } from "@millimetric/track-node";
track({
  event: "purchase",
  anonymous_id: req.cookies.aid,
  user_id: user.id,
  properties: { amount_cents: 4900 }
});
```

### MCP

```json
{ "name": "track_event",
  "arguments": { "event": "purchase", "anonymous_id": "u_abc",
                 "properties": { "amount_cents": 4900 } } }
```


# POST /v1/batch

POST /v1/batch — emit up to 1000 events in one call.

Send up to 1000 events in a single request. Used by the browser SDK for buffered flushes (every 20 events or every 2 seconds, whichever comes first).

## Auth

|                |                 |
| -------------- | --------------- |
| Required scope | `ingest`        |
| Key kinds      | `pk_*` · `sk_*` |

## Request

```http
POST /v1/batch
Authorization: Bearer {key}
Origin: {your-origin}              ← only for pk_* keys
Content-Type: application/json
```

```json
{
  "events": [
    { "event": "$pageview", "anonymous_id": "u_abc",
      "url": "https://yoursite.com/?fbclid=abc" },
    { "event": "click",     "anonymous_id": "u_abc",
      "properties": { "button": "cta" } }
  ]
}
```

Each element in `events` follows the [same schema as `POST /v1/track`](/api-reference/track).

## Response

```http
HTTP/1.1 202 Accepted
Content-Type: application/json
```

```json
{ "ok": true, "count": 2 }
```

## Limits

* **Max events per batch**: 1000. Over the limit returns `400 invalid_payload`.
* **Total payload size**: bounded by the 8 KB-per-event `properties` cap × event count.
* **Rate limit**: 5 batch requests/sec, burst of 20 per project (separately from `/v1/track`).

## Atomicity

A batch is **all-or-nothing at the request level** — the Worker either inserts every event or returns an error. ClickHouse itself batches the insert as a single `JSONEachRow` payload. If validation fails on event #4 of 50, no events are written.

## Why batch

* **Browser SDK** uses it to coalesce navigations + interactions, reducing request volume by \~20×.
* **Server backfills** of historical data — pipe N events from a CSV, batch into chunks of 1000.
* **Idempotent replays** — if you set `event_id` on each event, you can safely re-run a batch (no server-side dedup yet — that's still your job).

## Example: server backfill

```js
import fs from "node:fs/promises";
const events = JSON.parse(await fs.readFile("backfill.json", "utf8"));

for (let i = 0; i < events.length; i += 1000) {
  const chunk = events.slice(i, i + 1000);
  await fetch("https://api.millimetric.ai/v1/batch", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SK_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ events: chunk })
  });
}
```


# POST /v1/identify

POST /v1/identify — link an anonymous\_id to a user\_id.

Bind a known `user_id` to the visitor's `anonymous_id`. Persists as a special `$identify` event so future analysis can stitch pre- and post-login behavior together.

## Auth

|                |                 |
| -------------- | --------------- |
| Required scope | `ingest`        |
| Key kinds      | `pk_*` · `sk_*` |

## Request

```http
POST /v1/identify
Authorization: Bearer {key}
Content-Type: application/json
```

```json
{
  "anonymous_id": "u_abc",
  "user_id": "user_42",
  "traits": { "email": "matt@example.com", "plan": "pro" }
}
```

| Field          | Required | Notes                                                                                |
| -------------- | -------- | ------------------------------------------------------------------------------------ |
| `anonymous_id` | **yes**  | The pseudonymous id the visitor's browser/server has been using.                     |
| `user_id`      | **yes**  | Your stable internal user id.                                                        |
| `traits`       | no       | Free-form attributes about the user, stored on the `$identify` event's `properties`. |

## Response

```json
{ "ok": true }
```

## How it integrates

After `/v1/identify`:

1. Subsequent events should include `"user_id"` on each `track` call. The SDK does this automatically once you call `identify()`.
2. Historical events for the same `anonymous_id` remain — they're not retroactively rewritten. Joins for user-level analysis use the `$identify` event as a stitch point.
3. The `sessions_v` view sees `user_id` on the `$identify` event itself; running totals per user are easy.

## Example query: time-to-conversion per user

```sql
SELECT
  user_id,
  MIN(if(event_name = '$identify', timestamp, NULL)) AS signed_up_at,
  MIN(if(event_name = 'purchase', timestamp, NULL)) AS first_purchase
FROM events
WHERE project_id = '...'
  AND timestamp > now() - INTERVAL 30 DAY
  AND user_id IS NOT NULL
GROUP BY user_id;
```


# POST /v1/forget

POST /v1/forget — GDPR delete for a user.

Permanently delete every event for `(project_id, user_id)`. Intended for GDPR / CCPA Right-to-be-Forgotten requests.

## Auth

|                |                                                                                                   |
| -------------- | ------------------------------------------------------------------------------------------------- |
| Required scope | `ingest` or `admin`                                                                               |
| Key kinds      | **`sk_*` only** — `pk_*` is explicitly rejected to prevent a leaked browser key from wiping data. |

## Request

```http
POST /v1/forget
Authorization: Bearer sk_live_…
Content-Type: application/json
```

```json
{ "user_id": "user_42" }
```

| Field     | Required | Notes                                                            |
| --------- | -------- | ---------------------------------------------------------------- |
| `user_id` | **yes**  | The stable user id you've been tagging events with. 1–256 chars. |

## Response

```json
{ "ok": true, "queued": true }
```

The mutation is queued on ClickHouse (`ALTER TABLE … DELETE`) and typically completes within seconds, occasionally minutes for very large tables.

## What gets deleted

* Every row in `events` where `project_id = <your project>` and `user_id = <user>`.
* Aggregated rows in `daily_rollup` and `sessions` **are not** rewritten — they're aggregates, not personal data. If you need to also strip rollup contributions, run a full re-aggregation from the surviving raw events.

## What does NOT get deleted

* Anonymous events for the same person *before* you called `/v1/identify`. They have no `user_id` and are indistinguishable from any other anonymous traffic.
* Events under other projects (the deletion is scoped to the project owning the key).

If you need to forget by `anonymous_id` instead of `user_id`, run the equivalent SQL directly against ClickHouse:

```sql
ALTER TABLE events DELETE
  WHERE project_id = '{project_id}'
    AND anonymous_id = '{anonymous_id}';
```

## Audit

Issue a `/v1/forget` call from a logged-only context (a script, a request handler with audit logs) — the Worker itself doesn't yet write a structured audit row. That's on the roadmap.

## Errors

* `403 forget_requires_secret_key` — you used a `pk_*` key.
* `500 forget_failed` — ClickHouse rejected the mutation. Check Worker logs.


# GET /v1/query

GET /v1/query — return raw events filtered by time range.

Return raw event rows matching a time range and optional filters. For debugging, ad-hoc inspection, and small reports.

## Auth

|                |        |
| -------------- | ------ |
| Required scope | `read` |
| Key kinds      | `rk_*` |

## Request

```http
GET /v1/query?from=2026-05-01T00:00:00Z&to=2026-05-16T00:00:00Z&event=signup&limit=100
Authorization: Bearer rk_live_…
```

### Query parameters

| Name      | Required | Notes                                                   |
| --------- | -------- | ------------------------------------------------------- |
| `from`    | **yes**  | ISO 8601 timestamp (inclusive).                         |
| `to`      | **yes**  | ISO 8601 timestamp (exclusive).                         |
| `event`   | no       | Filter to one event name (`signup`, `$pageview`, etc.). |
| `source`  | no       | Filter to one source (`facebook`, `google`, etc.).      |
| `medium`  | no       | Filter to one medium (`paid`, `social`, etc.).          |
| `user_id` | no       | Filter to events tagged with this `user_id`.            |
| `limit`   | no       | 1–1000, default 100.                                    |

## Response

```json
{
  "rows": [
    {
      "timestamp": "2026-05-16T19:31:23.217Z",
      "event_name": "signup",
      "anonymous_id": "u_fbpaid_1",
      "user_id": "user_001",
      "source": "facebook",
      "medium": "paid",
      "campaign": "spring",
      "source_confidence": "high",
      "referrer": "https://l.facebook.com/",
      "url": "https://yoursite.com/?utm_source=facebook&utm_medium=cpc&fbclid=abc",
      "path": "/",
      "country": "GB",
      "device_type": "desktop",
      "properties": "{\"plan\":\"free\"}"
    }
  ]
}
```

`properties` is returned as a JSON string (because ClickHouse stores it as `String`). Parse client-side.

## When NOT to use this

For aggregations (`count`, `uniq`, `group by`), use [`/v1/stats`](/api-reference/stats) — it's an order of magnitude cheaper than fetching raw rows and aggregating in your code.

For top-source breakdowns specifically, use [`/v1/sources`](/api-reference/sources).

## Example: last 20 events from a specific user

```bash
curl -G "https://api.millimetric.ai/v1/query" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-05-17T00:00:00Z" \
  --data-urlencode "user_id=user_42" \
  --data-urlencode "limit=20"
```

## Example: paid Facebook traffic this week

```bash
curl -G "https://api.millimetric.ai/v1/query" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-10T00:00:00Z" \
  --data-urlencode "to=2026-05-17T00:00:00Z" \
  --data-urlencode "source=facebook" \
  --data-urlencode "medium=paid"
```


# GET /v1/stats

GET /v1/stats — aggregations over events.

Aggregate counts or unique-visitor counts over a time range, optionally grouped and/or time-bucketed.

## Auth

|                |        |
| -------------- | ------ |
| Required scope | `read` |
| Key kinds      | `rk_*` |

## Request

```http
GET /v1/stats?metric=count&from=…&to=…&event=signup&group_by=source,medium&interval=day
Authorization: Bearer rk_live_…
```

### Query parameters

| Name       | Required | Notes                                                                                                                  |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `metric`   | no       | `count` (default) or `uniques` (unique `anonymous_id`s, HyperLogLog estimate).                                         |
| `from`     | **yes**  | ISO 8601 timestamp (inclusive).                                                                                        |
| `to`       | **yes**  | ISO 8601 timestamp (exclusive).                                                                                        |
| `event`    | no       | Filter to one event name.                                                                                              |
| `group_by` | no       | Comma-separated columns. Allowed: `event_name`, `source`, `medium`, `country`, `device_type`, `browser`, `os`, `path`. |
| `interval` | no       | `hour`, `day`, or `week`. Adds a `bucket` column to the output.                                                        |
| `limit`    | no       | 1–1000, default 100.                                                                                                   |

## Response

```json
{
  "metric": "count",
  "rows": [
    { "source": "facebook", "medium": "paid",   "value": 6 },
    { "source": "facebook", "medium": "social", "value": 3 },
    { "source": "google",   "medium": "paid",   "value": 3 }
  ]
}
```

When `interval` is set, each row also has a `bucket` column:

```json
{ "bucket": "2026-05-16 00:00:00", "source": "facebook", "value": 4 }
```

## Examples

### Daily signups by source

```bash
curl -G "https://api.millimetric.ai/v1/stats" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "metric=count" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-05-17T00:00:00Z" \
  --data-urlencode "event=signup" \
  --data-urlencode "group_by=source,medium" \
  --data-urlencode "interval=day"
```

### Unique visitors per country, last 7 days

```bash
curl -G "https://api.millimetric.ai/v1/stats" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "metric=uniques" \
  --data-urlencode "from=$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ)" \
  --data-urlencode "to=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --data-urlencode "group_by=country"
```

### Total page views this month

```bash
curl -G "https://api.millimetric.ai/v1/stats" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-06-01T00:00:00Z" \
  --data-urlencode "event=\$pageview"
```

(Note the `\$` to escape the `$` in shell. In code, just send `"$pageview"`.)

## Errors

* `400 invalid_group_by` — column not in the allow-list. Response includes the allowed columns.
* `400 invalid_params` — `from`/`to` missing or `metric` not one of `count`/`uniques`.


# GET /v1/sources

GET /v1/sources — top sources/mediums with the FB social-vs-paid split.

Top sources (and optionally source × medium) for the project. **This is the endpoint that surfaces the Facebook social-vs-paid split.**

## Auth

|                |        |
| -------------- | ------ |
| Required scope | `read` |
| Key kinds      | `rk_*` |

## Request

```http
GET /v1/sources?from=…&to=…&breakdown=source_medium
Authorization: Bearer rk_live_…
```

### Query parameters

| Name        | Required | Notes                                  |
| ----------- | -------- | -------------------------------------- |
| `from`      | **yes**  | ISO 8601 timestamp (inclusive).        |
| `to`        | **yes**  | ISO 8601 timestamp (exclusive).        |
| `breakdown` | no       | `source_medium` (default) or `source`. |
| `limit`     | no       | 1–200, default 50.                     |

## Response

```json
{
  "breakdown": "source_medium",
  "rows": [
    { "source": "facebook", "medium": "paid",   "events": 6, "uniques": 6, "paid_share": 1.0 },
    { "source": "facebook", "medium": "social", "events": 3, "uniques": 3, "paid_share": 0.0 },
    { "source": "google",   "medium": "paid",   "events": 3, "uniques": 3, "paid_share": 1.0 },
    { "source": "direct",   "medium": "direct", "events": 7, "uniques": 7, "paid_share": 0.0 }
  ]
}
```

Each row:

* `events` — total events matching the breakdown.
* `uniques` — distinct `anonymous_id`s (HyperLogLog estimate).
* `paid_share` — `count(medium='paid') / count(*)` for the row. For `breakdown=source_medium` it's trivially 0 or 1; for `breakdown=source` it reveals what fraction of a source's traffic is paid (useful for "X% of our Facebook is paid").

## Examples

### "What's our Facebook social vs paid split this month?"

```bash
curl -G "https://api.millimetric.ai/v1/sources" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-06-01T00:00:00Z"
# rows include separate facebook/paid and facebook/social entries
```

### "Where does our traffic come from, ignoring paid/organic?"

```bash
curl -G "https://api.millimetric.ai/v1/sources" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-06-01T00:00:00Z" \
  --data-urlencode "breakdown=source"
# rows include a paid_share column for each source
```

## Related

* For more flexible aggregations, use [`/v1/stats`](/api-reference/stats) with `group_by=source,medium`.
* For session-level entry source (better for revenue attribution), query `sessions_v` directly.


# Browser — @millimetric/track

@millimetric/track — the browser SDK. \~1.8 KB gzipped.

The browser SDK does the boring stuff so you don't have to: anonymous ID in `localStorage`, batched flushes, `sendBeacon` on `pagehide`, SPA history patching, attribution params captured per event, DNT/GPC bail-out.

## Install (one of two ways)

### A. Drop-in `<script>` snippet — for static sites, Webflow, Framer, marketing pages

```html
<script async
  src="https://api.millimetric.ai/v1/a.js"
  data-key="pk_live_…"
  data-host="https://api.millimetric.ai"></script>
```

The Worker that ingests events also serves the browser loader from the same hostname, so one deploy + one DNS record is all you need.

That's it. The snippet:

* Initialises the SDK with your `pk_*` key.
* Auto-fires `$pageview` on load and on every SPA navigation.
* Exposes a global `window.mm` you can use for custom events:

```html
<button onclick="window.mm.track('clicked_pricing', { plan: 'pro' })">
  See plans
</button>
```

### B. npm — for React / Next / Vue / Svelte / Solid

```bash
npm i @millimetric/track
```

```ts
import { init, track, identify, page } from "@millimetric/track";

init({ key: "pk_live_…" });
```

You can call `init()` more than once with the same key — it's idempotent.

## Public API

### `init(options)`

```ts
type InitOptions = {
  /** Your pk_* key. */
  key: string;
  /** Worker base URL. Defaults to same-origin. */
  host?: string;
  /** Auto-fire $pageview on init and on SPA navigation. Default true. */
  autoPageView?: boolean;
  /** Flush after this many ms even if batch isn't full. Default 2000. */
  flushIntervalMs?: number;
  /** Flush when queue reaches this size. Default 20. */
  flushBatchSize?: number;
  /** Override DNT / GPC checks. Default false. */
  ignoreOptOut?: boolean;
};
```

### `track(event, properties?)`

```ts
track("signup", { plan: "free" });
track("clicked_pricing");
```

`event` is required, 1–128 chars. `properties` is free-form JSON, capped at 8 KB after `JSON.stringify`.

### `identify(userId, traits?)`

```ts
identify(user.id, { email: user.email });
```

Emits a `$identify` event and attaches `user_id` to every subsequent `track()` call within this session.

### `page(name?, properties?)`

Emit a `$pageview` explicitly. Auto-fired by the SDK on `init()` and on SPA navigations; call it directly only if you need finer control (e.g. tracking a virtual page inside a modal).

```ts
page("checkout/payment", { step: 2 });
```

### `flush()`

```ts
await flush();
```

Forces an immediate batch send. Useful before navigating away in a controlled flow.

### `getAnonymousId()` / `setAnonymousId(id)`

```ts
const id = getAnonymousId();         // current UUID
setAnonymousId(req.cookies.aid);     // override e.g. from server-rendered HTML
```

## What's captured automatically

On every event, the SDK attaches as `properties`:

```ts
{
  utm_source, utm_medium, utm_campaign, utm_content, utm_term,
  fbclid, gclid, ttclid, msclkid, li_fat_id, dclid, gbraid, wbraid, yclid,
  $viewport_w, $viewport_h,
  $language,                  // navigator.language
  $timezone                   // Intl.DateTimeFormat().resolvedOptions().timeZone
}
```

Plus on the event itself:

```ts
{ url, path, referrer, anonymous_id, user_id, timestamp }
```

The classifier on the server uses `url` and `referrer` to determine `source`/`medium`/`campaign`.

## SPA navigation

The SDK patches `history.pushState` and `history.replaceState` and listens to `popstate`. Every navigation triggers a `$pageview` on the next microtask (so `document.title` reflects the new page).

If you'd rather track page views yourself:

```ts
init({ key, autoPageView: false });
// then call page() at your own discretion
```

## Batching & sendBeacon

* Events are queued in-memory.
* Flushed when the queue hits 20 events **or** 2 seconds after the first queued event (whichever comes first).
* On `pagehide` / `visibilitychange: hidden`, the SDK calls `navigator.sendBeacon` so in-flight events survive page exits.
* On a `5xx` response, the batch is requeued at the head for the next attempt.
* On a `4xx`, the batch is dropped (something is wrong with the payload).

## DNT / Global Privacy Control

The SDK short-circuits everything — no fetches, no `localStorage` writes — when either of these is set:

```js
navigator.doNotTrack === "1"
navigator.globalPrivacyControl === true
```

Override only if you have a separate legal basis:

```ts
init({ key, ignoreOptOut: true });
```

## Cookies

None. The anonymous ID lives in `localStorage` under the key `mm_aid`. If `localStorage` is unavailable (private mode), the SDK falls back to an in-memory UUID that lasts for the tab's lifetime.

## Bundle size

| Build                                     | Size                             |
| ----------------------------------------- | -------------------------------- |
| `dist/a.js` (CDN snippet, IIFE, minified) | **\~4.1 KB**                     |
| Gzipped over the wire                     | **\~1.8 KB**                     |
| npm ESM (tree-shakable)                   | similar, depends on your bundler |

## Examples

### React

```tsx
import { useEffect } from "react";
import { init, track, identify } from "@millimetric/track";

export function AnalyticsRoot({ children }) {
  useEffect(() => {
    init({ key: import.meta.env.VITE_AOA_KEY });
  }, []);
  return children;
}

// later
function Pricing() {
  return (
    <button onClick={() => track("clicked_pricing", { plan: "pro" })}>
      See plans
    </button>
  );
}
```

### Next.js (App Router)

```tsx
// app/layout.tsx
"use client";
import { useEffect } from "react";
import { init } from "@millimetric/track";

export default function RootLayout({ children }) {
  useEffect(() => { init({ key: process.env.NEXT_PUBLIC_AOA_KEY! }); }, []);
  return <html>{children}</html>;
}
```

### Vanilla HTML with explicit tracking

```html
<script async
  src="https://api.millimetric.ai/v1/a.js"
  data-key="pk_live_…"
  data-host="https://api.millimetric.ai"></script>

<form onsubmit="window.mm.track('subscribed', { source: 'footer' })">
  …
</form>
```

## How `a.js` is served

The same Cloudflare Worker that ingests events serves the loader at `GET /v1/a.js`. There is no separate CDN — one Worker, one route, one DNS record (`api.millimetric.ai`).

### Build & deploy

```bash
pnpm --filter @millimetric/api deploy
```

The `predeploy` hook runs `scripts/build-snippet.mjs`, which bundles `packages/sdk-browser/src/snippet.ts` with esbuild and embeds the minified IIFE into the Worker as a string. The route responds with:

* `Content-Type: application/javascript; charset=utf-8`
* `Cache-Control: public, max-age=300, stale-while-revalidate=86400`
* `Access-Control-Allow-Origin: *`
* `Cross-Origin-Resource-Policy: cross-origin`

### Point your hostname at it

In `apps/api/wrangler.toml`, uncomment the `[[routes]]` block:

```toml
[[routes]]
pattern = "api.millimetric.ai"
custom_domain = true
```

With `custom_domain = true`, Cloudflare auto-creates a proxied DNS record on your first deploy — no manual DNS step. After that:

```bash
curl -I https://api.millimetric.ai/v1/a.js
# HTTP/2 200
# content-type: application/javascript; charset=utf-8
# x-snippet-bytes: 4193
```


# Node — @millimetric/track-node

@millimetric/track-node — server-side SDK. Node 18+ / Bun / Deno.

A thin wrapper over `POST /v1/track` and `POST /v1/batch` with batching, retries on `5xx`, and exponential backoff. Zero dependencies.

## Install

```bash
npm i @millimetric/track-node
```

Requires Node 18+ (global `fetch`). Works in Bun and Deno too.

## Quick start

```ts
import { init, track, flush } from "@millimetric/track-node";

init({ key: process.env.AOA_SK!, host: "https://api.millimetric.ai" });

// somewhere in a request handler:
track({
  event: "purchase",
  anonymous_id: req.cookies.aid,
  user_id: user.id,
  properties: { amount_cents: 4900, currency: "usd" }
});

// before a serverless function returns:
await flush();
```

## Public API

### `init(options) → MillimetricClient`

```ts
type ClientOptions = {
  /** Your sk_* key (use sk_ on servers, not pk_). */
  key: string;
  /** Worker base URL. */
  host: string;
  /** Flush at N queued events. Default 1 (send immediately). */
  flushAt?: number;
  /** Flush after this many ms. Default 1000. */
  flushIntervalMs?: number;
  /** Retries on 5xx / network errors. Default 3. */
  maxRetries?: number;
};
```

You can also construct a client directly without the singleton:

```ts
import { MillimetricClient } from "@millimetric/track-node";
const client = new MillimetricClient({ key, host });
client.track({ event: "x" });
await client.flush();
```

### `track(event)`

```ts
track({
  event: "signup",
  anonymous_id: "u_abc",
  user_id: "user_42",
  properties: { plan: "free" }
});
```

Same field shape as the [`POST /v1/track`](/api-reference/track) HTTP body.

### `flush()`

```ts
await flush();
```

Flushes the queue synchronously. **Call this before a serverless function exits** — otherwise queued events may be lost when the function is frozen.

## Retry behavior

* `5xx` response → retried with exponential backoff (100, 200, 400, 800 ms).
* Network error → same.
* `4xx` response → thrown immediately, no retry. (The payload is bad; retrying won't help.)
* After `maxRetries` attempts, the in-flight batch is re-added to the head of the queue and the error bubbles up from `flush()`.

## Batching

`flushAt` controls how many events to accumulate before sending:

```ts
init({ key, host, flushAt: 50, flushIntervalMs: 5000 });
```

With `flushAt > 1`, the SDK uses `POST /v1/batch`. With `flushAt = 1` (the default), it uses `POST /v1/track` directly — no extra latency.

The internal timer is `unref()`'d so it won't keep a Node process alive on its own. You still need `flush()` before exit.

## Pattern: long-running server

```ts
init({ key, host, flushAt: 50, flushIntervalMs: 2000 });

// fire and forget — the SDK batches in the background
app.post("/signup", async (req, res) => {
  // … your signup logic …
  track({ event: "signup", anonymous_id: req.cookies.aid, user_id: user.id });
  res.json({ ok: true });
});

// on shutdown:
process.on("SIGTERM", async () => { await flush(); process.exit(0); });
```

## Pattern: serverless (Vercel / Cloudflare / Lambda)

```ts
init({ key, host, flushAt: 1 });   // send immediately

export async function POST(req: Request) {
  // … handle the request …
  track({ event: "form_submit", anonymous_id });
  await flush();                    // important — function freezes after this returns
  return new Response("ok");
}
```

## Pattern: background backfill

```ts
import { MillimetricClient } from "@millimetric/track-node";
import fs from "node:fs/promises";

const client = new MillimetricClient({ key: process.env.AOA_SK!, host, flushAt: 1000 });

const events = JSON.parse(await fs.readFile("backfill.json", "utf8"));
for (const e of events) client.track(e);
await client.flush();
```


# curl & raw HTTP

Plain HTTP — no SDK required.

You don't need an SDK. Every Millimetric endpoint is a plain HTTPS call with a `Bearer` token. Useful for shell scripts, GitHub Actions, cron jobs, server backfills, or anything we don't ship a library for yet.

## Auth

```http
Authorization: Bearer {key}
Content-Type: application/json
```

For `pk_*` keys, also include `Origin:` matching the project's allowlist (`pk_*` from servers will fail without it).

## Ingest one event

```bash
curl -X POST https://api.millimetric.ai/v1/track \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "signup",
    "anonymous_id": "u_abc",
    "user_id": "user_42",
    "properties": { "plan": "free" }
  }'
```

Expected: `202 {"ok":true,"event_id":null}`.

## Ingest a batch

```bash
curl -X POST https://api.millimetric.ai/v1/batch \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "event": "$pageview", "anonymous_id": "u_a", "url": "https://yoursite.com/?utm_source=facebook&utm_medium=cpc" },
      { "event": "signup",     "anonymous_id": "u_a", "user_id": "user_1" }
    ]
  }'
```

## Identify

```bash
curl -X POST https://api.millimetric.ai/v1/identify \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "anonymous_id": "u_abc",
    "user_id": "user_42",
    "traits": { "email": "matt@example.com", "plan": "pro" }
  }'
```

## Forget a user

```bash
curl -X POST https://api.millimetric.ai/v1/forget \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "user_42" }'
```

## Query — top sources this month

```bash
curl -G "https://api.millimetric.ai/v1/sources" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-06-01T00:00:00Z" \
  | jq
```

## Query — daily signups by source

```bash
curl -G "https://api.millimetric.ai/v1/stats" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "metric=count" \
  --data-urlencode "event=signup" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-06-01T00:00:00Z" \
  --data-urlencode "group_by=source,medium" \
  --data-urlencode "interval=day"
```

## Browser-side (`pk_*`) — needs `Origin`

```bash
curl -X POST http://localhost:8787/v1/track \
  -H "Authorization: Bearer $PK_KEY" \
  -H "Origin: http://localhost:5173" \
  -H "Content-Type: application/json" \
  -d '{ "event": "$pageview", "anonymous_id": "u_test", "url": "https://yoursite.com/?fbclid=abc" }'
```

If `Origin` is missing or not allowlisted you'll get:

```
403 {"error":"origin_not_allowed","origin":"http://localhost:5173"}
```

## Tips

* **Pipe to `jq`**: `curl … | jq` for legible JSON.
* **Save keys to a .env file**: `export SK_KEY=sk_live_…` and source it once per shell.
* **Use a fish/zsh function** for repeated queries — the URL-encoded query params can get long.
* **Logs from the Worker**: when something goes wrong, `wrangler tail` (or `pnpm dev:api` locally) shows the full traceback. The HTTP response only ever returns a short `error` code.


# Framework recipes

Drop-in setup for React, Next.js, Vue, Svelte, and other JS frameworks.

The browser SDK (`@millimetric/track`) is framework-agnostic — it's just `init`, `track`, `identify`, `page`, `flush`. This page collects the small, copy-paste-ready integrations for the frameworks people actually use.

If your framework isn't listed, the [pure-JS browser snippet](/sdks/browser#a-drop-in-script-snippet--for-static-sites-webflow-framer-marketing-pages) Just Works on any HTML page.

## Sub-recipes

* [React](/sdks/frameworks/react)
* [Next.js](/sdks/frameworks/nextjs)
* [Vue](/sdks/frameworks/vue)
* [Svelte / SvelteKit](/sdks/frameworks/svelte)

## Common pattern across all of them

Three things, in order:

1. **Init once** — at the top of your app, with your `pk_*` key.
2. **Identify on auth** — when you know who the visitor is.
3. **Track on intent** — buttons, conversions, the things you'd put in a funnel.

Auto-pageviews (including SPA navigations) are on by default; you usually don't have to call `page()` yourself.

## Where to put the key

| Framework               | Env var name (suggested) | Read at                                               |
| ----------------------- | ------------------------ | ----------------------------------------------------- |
| Vite (React/Vue/Svelte) | `VITE_AOA_KEY`           | `import.meta.env.VITE_AOA_KEY`                        |
| Next.js                 | `NEXT_PUBLIC_AOA_KEY`    | `process.env.NEXT_PUBLIC_AOA_KEY`                     |
| Nuxt                    | `NUXT_PUBLIC_AOA_KEY`    | `useRuntimeConfig().public.aoaKey`                    |
| SvelteKit               | `PUBLIC_AOA_KEY`         | `import { PUBLIC_AOA_KEY } from "$env/static/public"` |

The `pk_*` key is browser-safe — it's origin-allowlisted on the server. Never expose `sk_*` or `rk_*` to the browser.

## Anti-patterns

* **Calling `init()` in a render function.** Init is idempotent but inits-per-render is wasteful. Wrap in `useEffect` (React) / `onMount` (Svelte) / `mounted()` (Vue).
* **Tracking inside `useEffect` cleanup.** The cleanup runs on unmount and on every re-render — easy to over-track. Track on the user action (`onClick`), not on the effect lifecycle.
* **Calling `flush()` synchronously in render.** It returns a promise; awaiting it in render breaks SSR.
* **Setting `user_id` from your URL.** Use `identify(user.id)` once after auth resolves; don't pass `user_id` per-call.


# React

React (Vite, CRA, Remix client) + @millimetric/track.

## Install

```bash
npm i @millimetric/track
```

## Init at the root

```tsx
// src/main.tsx (Vite)
import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { init } from "@millimetric/track";
import App from "./App";

function Analytics({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    init({
      key: import.meta.env.VITE_AOA_KEY!,
      host: import.meta.env.VITE_AOA_HOST ?? "https://api.millimetric.ai"
    });
  }, []);
  return <>{children}</>;
}

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <Analytics>
      <App />
    </Analytics>
  </StrictMode>
);
```

The SDK auto-fires `$pageview` on init *and* on every `history.pushState` / `popstate`, so React Router and Tanstack Router are handled without extra wiring.

## Identify on login

```tsx
import { useEffect } from "react";
import { identify } from "@millimetric/track";
import { useUser } from "./auth";

export function IdentifyOnAuth() {
  const user = useUser();
  useEffect(() => {
    if (user) identify(user.id, { email: user.email, plan: user.plan });
  }, [user?.id]);
  return null;
}
```

Drop `<IdentifyOnAuth />` once near the top of your tree — usually next to your auth provider.

## Track on intent

```tsx
import { track } from "@millimetric/track";

export function PricingCard({ plan }: { plan: "free" | "pro" }) {
  return (
    <button onClick={() => track("clicked_pricing", { plan })}>
      Choose {plan}
    </button>
  );
}
```

## A tiny custom hook (optional)

```tsx
import { useCallback } from "react";
import { track } from "@millimetric/track";

export function useTrack() {
  return useCallback(
    (event: string, properties?: Record<string, unknown>) => track(event, properties),
    []
  );
}
```

It's a one-liner — no global provider needed. The SDK is its own singleton.

## React Router — manual page() if you turn off auto

Auto-pageviews handle most apps. Turn them off only if you need full control:

```tsx
init({ key, autoPageView: false });
```

```tsx
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { page } from "@millimetric/track";

export function ManualPageviews() {
  const location = useLocation();
  useEffect(() => {
    page(location.pathname);
  }, [location.pathname]);
  return null;
}
```

## React Server Components / SSR

Don't call SDK functions in components rendered on the server — the SDK reads `localStorage` and `navigator`. Mark anything analytics-related `"use client"` (Next.js / RSC) or render it inside a client-only boundary (Remix `ClientOnly`).

For Next.js specifically, see the [Next.js page](/sdks/frameworks/nextjs).

## Tracking errors / boundaries

```tsx
import { Component } from "react";
import { track } from "@millimetric/track";

export class TrackingErrorBoundary extends Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    track("ui_error", {
      message: error.message,
      component_stack: info.componentStack?.slice(0, 1000)
    });
  }

  render() {
    return this.state.hasError ? <p>Something went wrong.</p> : this.props.children;
  }
}
```


# Next.js

Next.js (App Router and Pages) + @millimetric/track. Covers RSC and edge.

## Install

```bash
npm i @millimetric/track          # browser
npm i @millimetric/track-node     # server-side
```

## App Router — the recommended pattern

A tiny client component you mount in the root layout:

```tsx
// app/_components/Analytics.tsx
"use client";

import { useEffect } from "react";
import { init } from "@millimetric/track";

export function Analytics() {
  useEffect(() => {
    init({
      key: process.env.NEXT_PUBLIC_AOA_KEY!,
      host: process.env.NEXT_PUBLIC_AOA_HOST ?? "https://api.millimetric.ai"
    });
  }, []);
  return null;
}
```

```tsx
// app/layout.tsx
import { Analytics } from "./_components/Analytics";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Analytics />
        {children}
      </body>
    </html>
  );
}
```

The SDK patches `history.pushState`, so App Router navigations auto-fire `$pageview` without any extra hook.

## Identify after auth resolves

```tsx
// app/_components/IdentifyOnAuth.tsx
"use client";

import { useEffect } from "react";
import { identify } from "@millimetric/track";
import { useUser } from "@/lib/auth";

export function IdentifyOnAuth() {
  const user = useUser();
  useEffect(() => {
    if (user) identify(user.id, { email: user.email, plan: user.plan });
  }, [user?.id]);
  return null;
}
```

Mount once next to `<Analytics />`. With Clerk / Auth.js / Supabase Auth, replace `useUser()` with the appropriate hook.

## Server-side events from a Route Handler

Use `@millimetric/track-node` with an `sk_*` key. **Never put `sk_*` in a `NEXT_PUBLIC_*` env var** — it'll ship to the browser bundle.

```ts
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { init, track, flush } from "@millimetric/track-node";

init({
  key: process.env.AOA_SK!,
  host: process.env.AOA_HOST!,
  flushAt: 1
});

export async function POST(req: Request) {
  const { amount_cents, currency, anonymous_id, user_id } = await req.json();

  track({
    event: "purchase",
    anonymous_id,
    user_id,
    properties: { amount_cents, currency }
  });

  await flush();   // important — function freezes after this returns
  return NextResponse.json({ ok: true });
}
```

For long-running route handlers (rare in serverless), `flushAt: 50` + a periodic `flush()` is fine.

## Server Actions

Same pattern. Init the Node SDK once at module scope; call `flush()` before the action returns.

```ts
"use server";

import { track, flush } from "@millimetric/track-node";

export async function createTeam(formData: FormData) {
  // ... your logic ...
  track({
    event: "team_created",
    user_id: session.userId,
    properties: { size: 1 }
  });
  await flush();
}
```

## Edge Runtime

`@millimetric/track-node` works on the edge — it uses global `fetch`, no Node built-ins. Just set `runtime`:

```ts
export const runtime = "edge";
```

## Pages Router

```tsx
// pages/_app.tsx
import { useEffect } from "react";
import { init } from "@millimetric/track";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { page } from "@millimetric/track";

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();

  useEffect(() => {
    init({ key: process.env.NEXT_PUBLIC_AOA_KEY! });
  }, []);

  useEffect(() => {
    const onChange = (url: string) => page(url);
    router.events.on("routeChangeComplete", onChange);
    return () => router.events.off("routeChangeComplete", onChange);
  }, [router.events]);

  return <Component {...pageProps} />;
}
```

(With Pages Router you do need to wire pageviews manually — `next/router` events don't go through `history.pushState` in the same way.)

## Don't init in middleware / RSC

Middleware runs per-request — initialising the browser SDK there does nothing useful. RSC components run on the server — they don't have `localStorage`. Keep all SDK calls inside `"use client"` boundaries.

## Env vars cheat-sheet

```bash
# .env.local
NEXT_PUBLIC_AOA_KEY=pk_live_...
NEXT_PUBLIC_AOA_HOST=https://api.millimetric.ai
AOA_SK=sk_live_...                # server-only
AOA_HOST=https://api.millimetric.ai
```

`NEXT_PUBLIC_*` ships to the browser. The plain `AOA_SK` does not — keep your secret keys naked, no prefix.


# Vue

Vue 3 (and Nuxt) + @millimetric/track.

## Install

```bash
npm i @millimetric/track
```

## Vue 3 (Vite)

Init in your entry file:

```ts
// src/main.ts
import { createApp } from "vue";
import { init } from "@millimetric/track";
import App from "./App.vue";
import router from "./router";

init({
  key: import.meta.env.VITE_AOA_KEY,
  host: import.meta.env.VITE_AOA_HOST ?? "https://api.millimetric.ai"
});

createApp(App).use(router).mount("#app");
```

The SDK auto-fires `$pageview` on `history.pushState`, so Vue Router navigations are picked up automatically.

## Identify on login

```ts
// composables/useIdentify.ts
import { watch } from "vue";
import { identify } from "@millimetric/track";
import { useUser } from "./useUser";

export function useIdentify() {
  const user = useUser();
  watch(
    () => user.value?.id,
    (id) => {
      if (id) identify(id, { email: user.value!.email, plan: user.value!.plan });
    },
    { immediate: true }
  );
}
```

Call `useIdentify()` once, e.g. in your root component's `setup()`.

## Track on intent

```vue
<script setup lang="ts">
import { track } from "@millimetric/track";

function onClickPricing(plan: "free" | "pro") {
  track("clicked_pricing", { plan });
}
</script>

<template>
  <button @click="onClickPricing('pro')">Choose Pro</button>
</template>
```

## A Vue plugin (optional)

If you'd rather inject the SDK as a plugin so `useTrack()` is available everywhere:

```ts
// plugins/millimetric.ts
import type { App } from "vue";
import { init, track, identify } from "@millimetric/track";

export default {
  install(app: App, options: { key: string; host?: string }) {
    init(options);
    app.provide("$mm", { track, identify });
  }
};
```

```ts
// main.ts
import millimetric from "./plugins/millimetric";
app.use(millimetric, { key: import.meta.env.VITE_AOA_KEY });
```

```vue
<script setup lang="ts">
import { inject } from "vue";
const mm = inject<{ track: typeof import("@millimetric/track").track }>("$mm")!;
</script>
```

The provided `track` and `identify` are the same functions — there's no advantage over importing them directly except for stricter test isolation.

## Nuxt 3

A client-only plugin:

```ts
// plugins/millimetric.client.ts
import { init } from "@millimetric/track";

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig();
  init({
    key: config.public.aoaKey,
    host: config.public.aoaHost ?? "https://api.millimetric.ai"
  });
});
```

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      aoaKey: "",
      aoaHost: "https://api.millimetric.ai"
    }
  }
});
```

```bash
# .env
NUXT_PUBLIC_AOA_KEY=pk_live_...
```

Then anywhere in components:

```vue
<script setup lang="ts">
import { track, identify } from "@millimetric/track";
</script>
```

## Server-side events from a Nitro route

Use `@millimetric/track-node` with an `sk_*` key:

```ts
// server/api/signup.post.ts
import { init, track, flush } from "@millimetric/track-node";

init({ key: process.env.AOA_SK!, host: process.env.AOA_HOST!, flushAt: 1 });

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  track({
    event: "signup",
    anonymous_id: body.anonymous_id,
    user_id: body.user_id,
    properties: { plan: body.plan }
  });
  await flush();
  return { ok: true };
});
```

Don't import `@millimetric/track-node` in client-only code — it expects `sk_*` keys that should never reach the browser.


# Svelte / SvelteKit

Svelte 5 / SvelteKit + @millimetric/track.

## Install

```bash
npm i @millimetric/track
```

## SvelteKit — root layout

```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { onMount } from "svelte";
  import { init } from "@millimetric/track";
  import { PUBLIC_AOA_KEY, PUBLIC_AOA_HOST } from "$env/static/public";

  let { children } = $props();

  onMount(() => {
    init({
      key: PUBLIC_AOA_KEY,
      host: PUBLIC_AOA_HOST ?? "https://api.millimetric.ai"
    });
  });
</script>

{@render children()}
```

The SDK patches `history.pushState`, so SvelteKit's client-side navigations auto-fire `$pageview`.

```bash
# .env
PUBLIC_AOA_KEY=pk_live_...
PUBLIC_AOA_HOST=https://api.millimetric.ai
```

## Identify on login

```svelte
<!-- src/lib/IdentifyOnAuth.svelte -->
<script lang="ts">
  import { identify } from "@millimetric/track";
  import { user } from "$lib/stores/auth";   // your auth store
  import { afterUpdate } from "svelte";

  $effect(() => {
    if ($user) identify($user.id, { email: $user.email, plan: $user.plan });
  });
</script>
```

Mount once in `+layout.svelte` next to your other auth-aware components.

(For Svelte 4, replace the `$effect` rune with a reactive statement: `$: if ($user) identify($user.id);`)

## Track on intent

```svelte
<script lang="ts">
  import { track } from "@millimetric/track";

  function onClickPricing(plan: "free" | "pro") {
    track("clicked_pricing", { plan });
  }
</script>

<button onclick={() => onClickPricing('pro')}>Choose Pro</button>
```

## Server-side events — `+server.ts`

Use `@millimetric/track-node` with an `sk_*` key. The variable goes in the **private** env namespace so it never ships to the client.

```ts
// src/lib/server/mm.ts
import { init, track, flush } from "@millimetric/track-node";
import { AOA_SK, AOA_HOST } from "$env/static/private";

init({ key: AOA_SK, host: AOA_HOST, flushAt: 1 });

export { track, flush };
```

```ts
// src/routes/api/signup/+server.ts
import { json } from "@sveltejs/kit";
import { track, flush } from "$lib/server/mm";

export async function POST({ request }) {
  const { anonymous_id, user_id, plan } = await request.json();

  track({
    event: "signup",
    anonymous_id,
    user_id,
    properties: { plan }
  });

  await flush();
  return json({ ok: true });
}
```

## Form actions

Same idea — call `track()` inside the `default` action, `flush()` before returning:

```ts
// src/routes/signup/+page.server.ts
import { fail } from "@sveltejs/kit";
import { track, flush } from "$lib/server/mm";

export const actions = {
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = String(data.get("email"));
    const aid = cookies.get("aid");

    // ... your signup logic ...

    track({
      event: "signup",
      anonymous_id: aid,
      user_id: createdUser.id,
      properties: { plan: "free" }
    });
    await flush();

    return { ok: true };
  }
};
```

## SSR safety

Don't import `@millimetric/track` in code that runs server-side — it touches `localStorage` and `navigator`. SvelteKit's `+page.server.ts` and `+layout.server.ts` files run on the server only; reach for `@millimetric/track-node` there instead.

`onMount` and `$effect` only fire on the client, so calling browser-SDK functions inside them is always safe.

## Plain Svelte (no SvelteKit)

Same flow, simpler init:

```svelte
<!-- App.svelte -->
<script lang="ts">
  import { onMount } from "svelte";
  import { init, track } from "@millimetric/track";

  onMount(() => init({ key: import.meta.env.VITE_AOA_KEY }));
</script>
```


# MCP server

The MCP server — AI agents emit and query analytics natively.

Millimetric ships a first-class **Model Context Protocol** server at `/mcp`. Any MCP client (Claude Code, Claude Desktop, MCP Inspector, custom agents) can connect and use the same five operations a developer would.

## Endpoints

Millimetric exposes two MCP servers. Pick the one that matches the credential you have:

| Endpoint            | Auth                       | Scope                                                                            | Plan     |
| ------------------- | -------------------------- | -------------------------------------------------------------------------------- | -------- |
| `POST /mcp`         | `sk_live_…` or `rk_live_…` | One project per key.                                                             | Pro+     |
| `POST /mcp/account` | `ak_live_…`                | Every project the account owns. Includes `list_projects` and `compare_projects`. | Business |

```
POST https://api.millimetric.ai/mcp
Authorization: Bearer {key}
```

The transport is **JSON-RPC 2.0 over HTTP** (the latest MCP HTTP transport — no SSE needed for stateless calls).

For multi-project setups (agencies, multi-product teams), jump to [Account MCP](#account-mcp) below.

## Auth

`/mcp` accepts **only server-side keys**: `rk_live_…` (read-only) and `sk_live_…` (read + ingest).

`pk_live_…` keys are **rejected** at `/mcp` with `403 key_kind_not_allowed`. `pk_` keys are designed to ship in browser JS for the tracking SDK, which means anyone with view-source on a customer's site can read them — they must never grant access to query a project's events. Generate `rk_` / `sk_` keys from the dashboard and paste them into your agent's config server-side.

| You give the agent | It can call                                                         |
| ------------------ | ------------------------------------------------------------------- |
| `rk_live_…`        | `query_events`, `get_stats`, `top_sources`, `funnel`, all resources |
| `sk_live_…`        | the read tools above **and** `track_event`                          |
| `pk_live_…`        | **rejected** — `pk_` is browser-only                                |

Use `rk_*` by default. Only hand the agent an `sk_*` key if it needs to emit events on its own behalf.

## Tools

### `track_event` — emit an event (requires `ingest`)

```json
{
  "name": "track_event",
  "arguments": {
    "event": "agent_task_completed",
    "anonymous_id": "agent_001",
    "user_id": "user_42",
    "properties": { "task": "refactor_billing", "duration_ms": 12_400 }
  }
}
```

### `query_events` — raw events (requires `read`)

```json
{
  "name": "query_events",
  "arguments": {
    "from": "2026-05-01T00:00:00Z",
    "to":   "2026-05-17T00:00:00Z",
    "event": "signup",
    "source": "facebook",
    "limit": 50
  }
}
```

### `get_stats` — aggregations (requires `read`)

```json
{
  "name": "get_stats",
  "arguments": {
    "metric": "uniques",
    "from": "2026-05-01T00:00:00Z",
    "to":   "2026-05-17T00:00:00Z",
    "group_by": "source,medium",
    "interval": "day"
  }
}
```

### `top_sources` — the FB social-vs-paid split (requires `read`)

```json
{
  "name": "top_sources",
  "arguments": {
    "from": "2026-05-01T00:00:00Z",
    "to":   "2026-06-01T00:00:00Z",
    "breakdown": "source_medium",
    "limit": 20
  }
}
```

## Resources

* **`events://recent`** — last 100 events for the authenticated project.
* **`schema://events`** — distinct event names observed in the last 30 days. Useful for agents that want to discover what they can query before guessing.

## Connecting from Claude Code

Add this to your MCP config (`~/.claude/config.json` or via the UI):

```json
{
  "mcpServers": {
    "millimetric": {
      "url": "https://api.millimetric.ai/mcp",
      "transport": "http",
      "headers": {
        "Authorization": "Bearer rk_live_…"
      }
    }
  }
}
```

After restart, ask Claude:

> "Use millimetric.top\_sources to show me the Facebook social-vs-paid split for the last 7 days."

## Connecting from MCP Inspector

```bash
npx @modelcontextprotocol/inspector
# point it at http://localhost:8787/mcp
# add an Authorization header: Bearer rk_live_…
```

You'll see the tools and resources listed and can call them interactively.

## Why MCP?

For AI-native products, telemetry is two-way: the agent does work and the agent answers questions about the work. Both sides talk to the same analytics surface, on the same protocol, with no SDK to bundle.

For non-AI products, MCP is still useful — it's a clean way to give a read-only consumer access to the data without exposing the database, and the same JSON-RPC handler can be wrapped in any UI you care to build.

## Response shape

Tool results return as MCP `content[]`:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      { "type": "text", "text": "[{\"source\":\"facebook\",\"medium\":\"paid\",\"events\":6,...}]" }
    ]
  }
}
```

The `text` is JSON-stringified — agents parse it back to data. (MCP doesn't yet have a first-class structured-result type for tools.)

## Errors

Standard JSON-RPC error envelope:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": { "code": -32000, "message": "insufficient_scope",
             "data": { "required": "ingest", "got": "read" } }
}
```

| Code   | Meaning                                                                 |
| ------ | ----------------------------------------------------------------------- |
| -32600 | invalid\_request — body not a valid JSON-RPC envelope.                  |
| -32601 | method\_not\_found — unknown method.                                    |
| -32602 | unknown\_tool / unknown\_resource / unhandled\_tool.                    |
| -32000 | tool\_failed (server error) / insufficient\_scope.                      |
| -32002 | plan\_limit — feature requires a higher tier (e.g. account-MCP on Pro). |
| -32004 | unknown\_project\_id / unknown\_project\_slug — account-MCP only.       |
| -32005 | no\_projects\_visible — account-MCP only.                               |

## Account MCP

`/mcp/account` is the multi-project endpoint. One `ak_live_…` key authenticates an agent against every project in the account — built for agencies and teams running multiple products on one Millimetric workspace.

It speaks the same JSON-RPC dialect as `/mcp`, so any MCP client can talk to both with the same code path. The only differences are:

* **Auth**: `ak_` keys only. `pk_/sk_/rk_` are rejected with `403 account_key_required`.
* **Plan**: Business tier (`account_mcp_access`). Pro accounts get `402` with `error: "plan_limit"`.
* **Tools**: every read tool gains optional `project_id` / `project_slug` / `project_ids` arguments. Omit them and the query spans every project the key can see.

### Tools

#### `list_projects`

Returns every project the key can read.

```json
{ "name": "list_projects", "arguments": {} }
```

#### `query_events` (multi-project)

```json
{
  "name": "query_events",
  "arguments": {
    "from": "2026-05-01T00:00:00Z",
    "to":   "2026-05-17T00:00:00Z",
    "project_slug": "acme-storefront",
    "limit": 50
  }
}
```

Omit `project_slug` / `project_id` / `project_ids` to scan every project. Rows include a `project_id` column when the query spans more than one so the agent can attribute them.

#### `get_stats` (group by project)

```json
{
  "name": "get_stats",
  "arguments": {
    "metric": "count",
    "from": "2026-05-10T00:00:00Z",
    "to":   "2026-05-17T00:00:00Z",
    "group_by": "project_id,source"
  }
}
```

#### `top_sources`

Same shape as `/mcp`, plus the optional project-selection args.

#### `compare_projects`

Ranks every project the key can see by a single metric — perfect for "which app had the most traffic this week?".

```json
{
  "name": "compare_projects",
  "arguments": {
    "from": "2026-05-10T00:00:00Z",
    "to":   "2026-05-17T00:00:00Z",
    "metric": "uniques"
  }
}
```

Returns one row per project, decorated with `slug` and `name`.

### Resources

* **`projects://all`** — every project the key can read.
* **`schema://events`** — distinct event names from the last 30 days **across every accessible project**.

### Connecting from Claude Code

```json
{
  "mcpServers": {
    "millimetric-account": {
      "url": "https://api.millimetric.ai/mcp/account",
      "transport": "http",
      "headers": {
        "Authorization": "Bearer ak_live_…"
      }
    }
  }
}
```

Then:

> "Use millimetric-account.compare\_projects to rank my apps by unique visitors over the last 7 days."


# Overview

End-to-end worked examples for the things people actually want to do.

Each page is a complete, copy-paste-ready walkthrough of a real-world flow: instrumenting it on the client, querying the resulting data, and the gotchas to watch for.

| Recipe                                                             | What it covers                                                                      |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| [Track a signup funnel](/recipes/signup-funnel)                    | Page view → CTA click → form view → signup → activated. Drop-off math at each step. |
| [E-commerce events](/recipes/ecommerce)                            | Product views, cart, checkout, refunds. Revenue & AOV queries.                      |
| [Marketing attribution dashboards](/recipes/marketing-attribution) | The Facebook social-vs-paid split. First-touch vs last-touch. Channel ROI.          |
| [Server-side events from a backend](/recipes/server-side)          | When to emit from the server, how to thread `anonymous_id`, batching at scale.      |
| [Link anonymous → known users](/recipes/anonymous-to-known)        | Stitching pre-login activity to the post-login user. Multi-device.                  |
| [GDPR right-to-be-forgotten](/recipes/gdpr-delete)                 | The `/v1/forget` flow end-to-end, including audit-trail patterns.                   |
| [Funnel & retention analysis](/recipes/funnels)                    | SQL templates for n-step funnels, day-N retention cohorts, time-to-event.           |

If a flow you need isn't here, the [Quickstart](/quickstart) + [Events](/core-concepts/events) + [Properties](/core-concepts/properties) cover the building blocks.

## A consistent example app

The recipes share a fictional product to keep examples concrete: **Acme Notes** — a SaaS notes app with a free plan, a Pro plan ($49/month), and team workspaces. When you see `acme.com` or `plan: "pro"` in a snippet, that's why.


# Track a signup funnel

A full signup funnel — pageview, CTA click, form view, signup, activation — with drop-off queries.

A signup funnel is the canonical "did this work?" question. We'll instrument five steps:

```
$pageview (landing)  →  clicked_cta  →  viewed_signup_form  →  signup  →  activated
```

…then query the conversion rate of each step and break it down by traffic source.

## Step 1 — instrument the funnel

### Landing page (auto)

The browser SDK already fires `$pageview` on load. Nothing to do.

### CTA click

```tsx
import { track } from "@millimetric/track";

export function HeroCTA() {
  return (
    <button
      onClick={() => track("clicked_cta", { location: "hero", label: "Start free" })}
    >
      Start free
    </button>
  );
}
```

### Form view (when the signup form renders)

```tsx
import { useEffect } from "react";
import { track } from "@millimetric/track";

export function SignupForm() {
  useEffect(() => {
    track("viewed_signup_form", { variant: "v2" });
  }, []);
  // ...
}
```

### Signup itself

Send from the **server** so failed clientside submits don't pollute the funnel. Tie it back to the visitor with the `anonymous_id` they were carrying.

```ts
// app/api/signup/route.ts
import { init, track, flush } from "@millimetric/track-node";

init({ key: process.env.AOA_SK!, host: process.env.AOA_HOST!, flushAt: 1 });

export async function POST(req: Request) {
  const { email, plan, anonymous_id } = await req.json();

  // ...your signup logic, get user.id back...

  // Stitch anonymous → known
  await fetch(`${process.env.AOA_HOST}/v1/identify`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AOA_SK}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      anonymous_id,
      user_id: user.id,
      traits: { plan, email }
    })
  });

  track({
    event: "signup",
    anonymous_id,
    user_id: user.id,
    properties: { plan, source: "web" }
  });

  await flush();
  return Response.json({ ok: true });
}
```

The browser sends `anonymous_id` along with the signup body — read it from your auth state or the SDK directly:

```ts
import { getAnonymousId } from "@millimetric/track";

await fetch("/api/signup", {
  method: "POST",
  body: JSON.stringify({ email, plan, anonymous_id: getAnonymousId() })
});
```

### Activation

"Activated" is product-specific — the moment a user does the thing your product is for. For Acme Notes, it's "created their first note that has at least 10 characters".

```ts
import { track } from "@millimetric/track";

export async function createNote(content: string) {
  // ...persist the note...
  if (content.length >= 10 && isFirstNote) {
    track("activated", { first_note_length: content.length });
  }
}
```

## Step 2 — verify in the dashboard

Quick smoke test before you query at scale:

```bash
curl -G "https://api.millimetric.ai/v1/query" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-05-17T00:00:00Z" \
  --data-urlencode "event=clicked_cta" \
  --data-urlencode "limit=10" | jq
```

You should see `clicked_cta` events with `properties` containing `location: "hero"`.

## Step 3 — funnel drop-off via /v1/stats

A simple count per step:

```bash
for ev in '$pageview' clicked_cta viewed_signup_form signup activated; do
  curl -sG "https://api.millimetric.ai/v1/stats" \
    -H "Authorization: Bearer $RK_KEY" \
    --data-urlencode "metric=uniques" \
    --data-urlencode "from=2026-05-01T00:00:00Z" \
    --data-urlencode "to=2026-05-17T00:00:00Z" \
    --data-urlencode "event=$ev" | jq -r ".metric, .rows[0].value"
done
```

→

```
uniques  4830     ← pageview
uniques  1144     ← clicked_cta
uniques   902     ← viewed_signup_form
uniques   314     ← signup
uniques   228     ← activated
```

Conversion rates: 24% (CTA), 79% (form view), 35% (signup), 73% (activation). End-to-end: 4.7%.

## Step 4 — the proper funnel query (ClickHouse)

The right way is a single window function over events, computing first-time-each-step per user:

```sql
WITH steps AS (
  SELECT
    anonymous_id,
    minIf(timestamp, event_name = '$pageview')         AS s1,
    minIf(timestamp, event_name = 'clicked_cta')       AS s2,
    minIf(timestamp, event_name = 'viewed_signup_form') AS s3,
    minIf(timestamp, event_name = 'signup')            AS s4,
    minIf(timestamp, event_name = 'activated')         AS s5
  FROM events
  WHERE project_id = '...'
    AND timestamp BETWEEN '2026-05-01' AND '2026-05-17'
  GROUP BY anonymous_id
)
SELECT
  countIf(s1 IS NOT NULL)                                            AS landed,
  countIf(s2 IS NOT NULL AND s2 >= s1)                               AS clicked,
  countIf(s3 IS NOT NULL AND s3 >= s2)                               AS viewed_form,
  countIf(s4 IS NOT NULL AND s4 >= s3)                               AS signed_up,
  countIf(s5 IS NOT NULL AND s5 >= s4)                               AS activated,
  round(countIf(s5 IS NOT NULL AND s5 >= s4) * 100.0 / countIf(s1 IS NOT NULL), 2) AS pct
FROM steps;
```

The `s_n >= s_{n-1}` predicates enforce ordering (a user who *signed up* before they *viewed the form* doesn't count as a valid funnel — they came back through a deep link).

## Step 5 — break down by source

Where are the highest-converting visitors coming from?

```sql
SELECT
  argMin(source, timestamp)  AS first_source,
  argMin(medium, timestamp)  AS first_medium,
  countIf(event_name = '$pageview')  AS landed,
  countIf(event_name = 'signup')     AS signed_up,
  round(countIf(event_name = 'signup') * 100.0 /
        countIf(event_name = '$pageview'), 2) AS pct
FROM events
WHERE project_id = '...'
  AND timestamp BETWEEN '2026-05-01' AND '2026-05-17'
GROUP BY anonymous_id
HAVING landed > 0
GROUP BY first_source, first_medium
ORDER BY signed_up DESC;
```

You'll see Facebook *paid* on a different row than Facebook *social* — that's the [classifier](/core-concepts/attribution) doing its job.

For per-session attribution (better for revenue), query the `sessions` view directly. See [Marketing attribution dashboards](/recipes/marketing-attribution).

## Common pitfalls

* **Tracking on the click handler&#x20;*****and*****&#x20;in `useEffect`** — you'll double-count. Pick one, usually the click handler.
* **Not threading `anonymous_id` to the server** — your `signup` event lands without it, and the funnel breaks at step 4. Pass it through your auth body.
* **Tracking activation on a re-render** — gate it with `isFirstNote`. Otherwise the metric is "users who edited a note", which isn't the same.
* **Forgetting to call `/v1/identify`** — your post-signup events keep `user_id = NULL` and you lose per-user retention. Call it once on signup.

## See also

* [Events](/core-concepts/events), [Properties](/core-concepts/properties).
* [Anonymous → known users](/recipes/anonymous-to-known).
* [Marketing attribution dashboards](/recipes/marketing-attribution).


# E-commerce events

A standard e-commerce taxonomy with revenue and AOV queries.

Standard e-commerce funnel:

```
viewed_product  →  added_to_cart  →  started_checkout  →  completed_checkout  →  refunded
```

Plus product browse signals (`viewed_collection`, `searched`) and post-purchase (`reviewed_product`).

## Suggested taxonomy

| Event                | When                        | Required properties                                                    |
| -------------------- | --------------------------- | ---------------------------------------------------------------------- |
| `viewed_product`     | product detail page renders | `product_id`, `price_cents`, `currency`, `category`                    |
| `added_to_cart`      | "Add to cart" button        | `product_id`, `quantity`, `price_cents`, `currency`                    |
| `removed_from_cart`  | item removed from cart      | `product_id`, `quantity`                                               |
| `started_checkout`   | checkout page load          | `cart_value_cents`, `currency`, `item_count`                           |
| `applied_discount`   | discount code applied       | `code`, `discount_cents`                                               |
| `completed_checkout` | order placed                | `order_id`, `amount_cents`, `currency`, `item_count`, `payment_method` |
| `refunded`           | refund issued               | `order_id`, `amount_cents`, `currency`, `reason`                       |

Money in **cents** as integers. `currency` always alongside any amount.

## Instrument the storefront

```tsx
import { track } from "@millimetric/track";

export function ProductPage({ product }: { product: Product }) {
  useEffect(() => {
    track("viewed_product", {
      product_id: product.id,
      price_cents: product.priceCents,
      currency: "usd",
      category: product.category
    });
  }, [product.id]);

  return (
    <button onClick={() => addToCart(product, 1)}>Add to cart</button>
  );
}

function addToCart(product: Product, qty: number) {
  cart.add(product, qty);
  track("added_to_cart", {
    product_id: product.id,
    quantity: qty,
    price_cents: product.priceCents,
    currency: "usd"
  });
}
```

## Track the order from the server

```ts
// app/api/checkout/complete/route.ts
import { init, track, flush } from "@millimetric/track-node";

init({ key: process.env.AOA_SK!, host: process.env.AOA_HOST!, flushAt: 1 });

export async function POST(req: Request) {
  const order = await chargeAndPersist(await req.json());

  track({
    event: "completed_checkout",
    anonymous_id: order.anonymous_id,
    user_id: order.user_id,
    properties: {
      order_id: order.id,
      amount_cents: order.totalCents,
      currency: order.currency,
      item_count: order.items.length,
      payment_method: order.paymentMethod,
      is_first_purchase: order.isFirstPurchase
    }
  });

  await flush();
  return Response.json({ ok: true, order_id: order.id });
}
```

For Stripe webhooks, do the same in your webhook handler and pass `order_id` so you can dedupe on retry.

## Refunds

```ts
track({
  event: "refunded",
  user_id: order.user_id,
  properties: {
    order_id: order.id,
    amount_cents: refund.amountCents,
    currency: order.currency,
    reason: refund.reason
  }
});
```

## Querying

### Revenue per day

```sql
SELECT
  toDate(timestamp) AS day,
  sum(JSONExtractInt(properties, 'amount_cents')) / 100.0 AS revenue_usd
FROM events
WHERE project_id = '...'
  AND event_name = 'completed_checkout'
  AND JSONExtractString(properties, 'currency') = 'usd'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY day
ORDER BY day;
```

### Average order value (AOV) by channel

```sql
SELECT
  argMin(source, timestamp)  AS source,
  argMin(medium, timestamp)  AS medium,
  count() AS orders,
  round(avg(JSONExtractInt(properties, 'amount_cents')) / 100.0, 2) AS aov_usd
FROM events
WHERE project_id = '...'
  AND event_name = 'completed_checkout'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY anonymous_id
GROUP BY source, medium
ORDER BY orders DESC;
```

### Funnel: view → cart → checkout → order

```sql
WITH steps AS (
  SELECT
    anonymous_id,
    minIf(timestamp, event_name = 'viewed_product')        AS s1,
    minIf(timestamp, event_name = 'added_to_cart')         AS s2,
    minIf(timestamp, event_name = 'started_checkout')      AS s3,
    minIf(timestamp, event_name = 'completed_checkout')    AS s4
  FROM events
  WHERE project_id = '...'
    AND timestamp BETWEEN '2026-05-01' AND '2026-05-17'
  GROUP BY anonymous_id
)
SELECT
  countIf(s1 IS NOT NULL) AS viewed,
  countIf(s2 IS NOT NULL AND s2 >= s1) AS added,
  countIf(s3 IS NOT NULL AND s3 >= s2) AS checked_out,
  countIf(s4 IS NOT NULL AND s4 >= s3) AS purchased
FROM steps;
```

### Cohort retention (do they come back?)

For the cohort that purchased in week W0, what fraction purchased again in week W1, W2, …?

```sql
WITH cohorts AS (
  SELECT
    user_id,
    toMonday(min(timestamp)) AS cohort_week
  FROM events
  WHERE project_id = '...' AND event_name = 'completed_checkout'
  GROUP BY user_id
),
purchases AS (
  SELECT user_id, toMonday(timestamp) AS week
  FROM events
  WHERE project_id = '...' AND event_name = 'completed_checkout'
)
SELECT
  c.cohort_week,
  dateDiff('week', c.cohort_week, p.week) AS week_offset,
  uniq(c.user_id) AS users
FROM cohorts c
JOIN purchases p USING user_id
WHERE p.week >= c.cohort_week
GROUP BY c.cohort_week, week_offset
ORDER BY c.cohort_week, week_offset;
```

## Common pitfalls

* **`amount` instead of `amount_cents`.** Floats are not your friend in revenue math. Always use integer cents and store `currency` explicitly.
* **Tracking `completed_checkout` from the success page.** Anyone refreshing it double-counts. Track from the server, on order creation, with `event_id = order.id` so you can dedupe in queries.
* **Mixing currencies.** Don't sum `amount_cents` across rows with different `currency` values. Convert in the query, or split by currency.
* **No `is_first_purchase` flag.** It's trivial to compute, easier in queries, and answers half the marketing-attribution questions cleanly.

## See also

* [Properties](/core-concepts/properties) — full conventions.
* [Marketing attribution dashboards](/recipes/marketing-attribution) — credit channels for revenue.
* [Server-side events](/recipes/server-side) — why critical events should come from the server.


# Marketing attribution dashboards

First-touch vs last-touch, the FB social-vs-paid split, channel ROI.

Most analytics tools give you one big "Facebook" bucket. Millimetric splits paid Facebook from organic Facebook automatically — and gives you the rule that fired so you can audit. This recipe shows the queries to build the dashboards every marketer asks for.

## What the classifier gives you

Every event row has:

```
source              "facebook" | "google" | "twitter" | "direct" | "internal" | ...
medium              "paid" | "organic" | "social" | "email" | "referral" | "direct"
campaign            utm_campaign, if any
source_confidence   "low" | "medium" | "high"
source_rule_id      which classifier rule fired
```

Full rule cascade in [Attribution](/core-concepts/attribution). Highlights:

* `gclid` → google/paid
* `fbclid` via `l.facebook.com` → facebook/paid (high)
* `fbclid` from elsewhere → facebook/paid (medium)
* `referrer = facebook.com` without `fbclid` → facebook/social
* `utm_source=facebook&utm_medium=cpc` → facebook/paid

## Channel breakdown — top of the dashboard

```bash
curl -G "https://api.millimetric.ai/v1/sources" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-06-01T00:00:00Z" \
  --data-urlencode "breakdown=source_medium" | jq
```

→

```json
[
  { "source": "facebook", "medium": "paid",   "events": 6, "uniques": 6, "paid_share": 1.0 },
  { "source": "facebook", "medium": "social", "events": 3, "uniques": 3, "paid_share": 0.0 },
  { "source": "google",   "medium": "paid",   "events": 3, "uniques": 3, "paid_share": 1.0 },
  { "source": "google",   "medium": "organic","events": 8, "uniques": 8, "paid_share": 0.0 },
  { "source": "direct",   "medium": "direct", "events": 7, "uniques": 7, "paid_share": 0.0 }
]
```

## First-touch vs last-touch

The classifier evaluates *each event* independently — that's per-event truth. For "where did this customer come from", attribute by **first touch on the visitor**, not the last event.

### First-touch per visitor

```sql
SELECT
  argMin(source, timestamp)  AS first_source,
  argMin(medium, timestamp)  AS first_medium,
  count() AS visitors
FROM events
WHERE project_id = '...'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY anonymous_id
GROUP BY first_source, first_medium
ORDER BY visitors DESC;
```

### Last-touch per visitor (just before conversion)

```sql
SELECT
  argMin(source, timestamp)        AS first_source,
  argMax(source, timestamp_signup) AS last_source_at_signup
FROM (
  SELECT
    anonymous_id,
    timestamp,
    source,
    if(event_name = 'signup', timestamp, NULL) AS timestamp_signup
  FROM events
  WHERE project_id = '...'
    AND timestamp > now() - INTERVAL 30 DAY
)
GROUP BY anonymous_id;
```

## Channel ROI — revenue per channel

Combines `completed_checkout` (revenue) with first-touch source (the channel that brought them in).

```sql
WITH attributed AS (
  SELECT
    e.anonymous_id,
    argMin(s.entry_source, s.started_at) AS first_source,
    argMin(s.entry_medium, s.started_at) AS first_medium
  FROM events e
  JOIN sessions s USING (project_id, anonymous_id)
  WHERE e.project_id = '...'
    AND e.event_name = 'completed_checkout'
    AND e.timestamp > now() - INTERVAL 30 DAY
  GROUP BY e.anonymous_id
)
SELECT
  a.first_source,
  a.first_medium,
  count(DISTINCT e.event_id) AS orders,
  sum(JSONExtractInt(e.properties, 'amount_cents')) / 100.0 AS revenue_usd
FROM events e
JOIN attributed a USING anonymous_id
WHERE e.project_id = '...'
  AND e.event_name = 'completed_checkout'
GROUP BY a.first_source, a.first_medium
ORDER BY revenue_usd DESC;
```

## Confidence-filtered attribution

When you want clean numbers (e.g. for board reporting), exclude low-confidence rows:

```sql
SELECT source, medium, count() AS events
FROM events
WHERE project_id = '...'
  AND source_confidence IN ('high', 'medium')   -- skip "low"
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY source, medium
ORDER BY events DESC;
```

Or *only* high-confidence:

```sql
WHERE source_confidence = 'high'
```

For `fbclid`-arrived events on non-Meta-redirect hosts, confidence is `medium` because `fbclid` can leak onto organic shares. Filter accordingly when revenue numbers matter.

## The Facebook social-vs-paid headline

This is the question the product was built to answer cleanly:

```sql
SELECT
  medium,
  count() AS events,
  uniq(anonymous_id) AS visitors,
  countIf(event_name = 'signup') AS signups,
  round(countIf(event_name = 'signup') * 100.0 / uniq(anonymous_id), 2) AS conv_pct
FROM events
WHERE project_id = '...'
  AND source = 'facebook'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY medium;
```

→

```
medium  | events | visitors | signups | conv_pct
paid    | 4321   | 3998     | 412     | 10.30
social  | 1872   | 1801     | 67      | 3.72
```

Most analytics tools give you the union of those two rows. You can now act on the difference: paid Facebook converts 2.8× organic — fine, double down on ads, and don't optimize organic for conversion.

## Per-rule audit (for the paranoid)

Want to see exactly which classifier rule fired most often this week?

```sql
SELECT
  source_rule_id,
  source,
  medium,
  count() AS events
FROM events
WHERE project_id = '...'
  AND timestamp > now() - INTERVAL 7 DAY
GROUP BY source_rule_id, source, medium
ORDER BY events DESC
LIMIT 30;
```

This is the dashboard for "is the classifier behaving how I expect on real traffic". When `unknown/low` shows up at the top, one of your campaigns is missing UTMs.

## Building this in your own dashboard

Hit `/v1/sources` and `/v1/stats` from a server-side (Node, Python, Ruby) backend with an `rk_*` key, then render charts however you like. Sample minimal Next.js handler:

```ts
// app/api/dashboard/sources/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const url = new URL("https://api.millimetric.ai/v1/sources");
  url.searchParams.set("from", new Date(Date.now() - 30 * 86400 * 1000).toISOString());
  url.searchParams.set("to", new Date().toISOString());
  url.searchParams.set("breakdown", "source_medium");

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.AOA_RK!}` },
    next: { revalidate: 60 }
  });
  return NextResponse.json(await res.json());
}
```

Keep the `rk_*` key on the server. Never ship it to the browser.

## See also

* [Attribution](/core-concepts/attribution) — full classifier rules.
* [GET /v1/sources](/api-reference/sources) — the dashboard endpoint.
* [Sessions](/core-concepts/sessions) — entry-source per visit.


# Server-side events from a backend

When to track from your backend, how to thread anonymous\_id, and patterns for batching.

Critical events should originate on the server. The browser is hostile territory: ad blockers, refreshes, navigations, network drops. If you only fire `purchase` from the success page, your revenue numbers are wrong.

## What to fire server-side

| Event                                       | Browser         | Server                        |
| ------------------------------------------- | --------------- | ----------------------------- |
| `$pageview`                                 | ✅ — auto by SDK | —                             |
| `clicked_*`, `viewed_*`, `hovered_*`        | ✅               | —                             |
| `signup`                                    | optional        | ✅ — fire on user creation     |
| `subscription_*`, `purchase`, `refunded`    | —               | ✅ — fire on transaction       |
| `feature_used` (gated by auth)              | optional        | ✅ — fire from the API handler |
| Webhook events from Stripe / Slack / Resend | —               | ✅                             |

## Threading anonymous\_id from the browser

The whole game is making sure server-side events still carry the visitor's `anonymous_id` — otherwise you can't stitch them back to the browser session. Three options, pick one.

### Option A — pass it in the request body

The simplest. The browser includes the SDK's anonymous id when calling your API.

```ts
import { getAnonymousId } from "@millimetric/track";

await fetch("/api/signup", {
  method: "POST",
  body: JSON.stringify({
    email,
    plan,
    anonymous_id: getAnonymousId()
  })
});
```

Server reads it and passes through:

```ts
const { email, plan, anonymous_id } = await req.json();
track({ event: "signup", anonymous_id, user_id: user.id, properties: { plan } });
```

### Option B — first-party cookie

If you'd rather not touch every fetch call. Mirror the SDK's `localStorage` value into a cookie:

```ts
import { getAnonymousId } from "@millimetric/track";

if (typeof document !== "undefined") {
  document.cookie = `aid=${getAnonymousId()}; path=/; max-age=31536000; SameSite=Lax`;
}
```

```ts
// server
const aid = req.cookies.aid;
track({ event: "signup", anonymous_id: aid, /* ... */ });
```

(This is the only "cookie" in the system, and it's *your* cookie, not Millimetric's. We never set one.)

### Option C — derive it server-side and tell the SDK

For SSR-heavy apps (Next.js with cookies, Rails with sessions), you might prefer the server to own the id and the browser to consume it.

```ts
// server: pass aid into HTML as a data attribute
return `<script>window.__aid = ${JSON.stringify(req.cookies.aid)}</script>`;
```

```ts
// client: tell the SDK to use it
import { init, setAnonymousId } from "@millimetric/track";
init({ key });
if (window.__aid) setAnonymousId(window.__aid);
```

## Long-running server vs serverless

```ts
import { init, track, flush } from "@millimetric/track-node";
```

### Long-running (Node server, Express, Fastify, Bun, Express on a VPS)

Batch in the background. The internal timer is `unref`'d, so it won't keep your process alive on its own.

```ts
init({
  key: process.env.AOA_SK!,
  host: process.env.AOA_HOST!,
  flushAt: 50,           // batch up to 50 events before sending
  flushIntervalMs: 2000  // …or every 2s
});

app.post("/signup", async (req, res) => {
  // ...
  track({ event: "signup", anonymous_id: req.cookies.aid, user_id: user.id });
  res.json({ ok: true });
});

process.on("SIGTERM", async () => {
  await flush();
  process.exit(0);
});
```

### Serverless / edge (Vercel, Cloudflare Workers, Lambda, Deno Deploy)

The instance freezes after the response. Always `await flush()` before returning, or queued events disappear.

```ts
init({ key: process.env.AOA_SK!, host: process.env.AOA_HOST!, flushAt: 1 });

export async function POST(req: Request) {
  // ...
  track({ event: "purchase", anonymous_id, user_id, properties: { amount_cents: 4900 } });
  await flush();
  return new Response("ok");
}
```

## Webhooks (Stripe, Slack, Resend, etc.)

Treat webhook events as server-side `track()` calls with idempotency. Use the provider's event id as `event_id`:

```ts
import Stripe from "stripe";

export async function POST(req: Request) {
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    req.headers.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    track({
      event: "completed_checkout",
      event_id: event.id,                              // Stripe's event id
      user_id: session.metadata?.user_id ?? undefined,
      properties: {
        order_id: session.id,
        amount_cents: session.amount_total ?? 0,
        currency: session.currency ?? "usd",
        is_first_purchase: session.metadata?.is_first === "true"
      }
    });
  }

  await flush();
  return Response.json({ received: true });
}
```

The server doesn't dedupe on `event_id` yet, so if Stripe retries the webhook you might get two rows. Either:

* **De-dupe in queries** (`SELECT DISTINCT … ORDER BY event_id`).
* **Track receipts in your own DB** and skip if already processed before calling `track()`.

## Batching backfills

Loading historical data — old orders from your DB into Millimetric — is exactly what `/v1/batch` is for.

```ts
import { MillimetricClient } from "@millimetric/track-node";
import fs from "node:fs/promises";

const client = new MillimetricClient({
  key: process.env.AOA_SK!,
  host: process.env.AOA_HOST!,
  flushAt: 1000              // batch up to 1000 events per request
});

const orders = JSON.parse(await fs.readFile("orders-2026.json", "utf8"));

for (const o of orders) {
  client.track({
    event: "completed_checkout",
    event_id: `order_${o.id}`,
    user_id: String(o.user_id),
    timestamp: o.created_at,
    properties: {
      order_id: o.id,
      amount_cents: o.total_cents,
      currency: o.currency
    }
  });
}

await client.flush();
console.log(`Backfilled ${orders.length} events.`);
```

`/v1/batch` is rate-limited to 5 requests/sec — at 1000 events/batch that's 5,000 events/sec sustained, fine for any reasonable backfill.

## Common pitfalls

* **Not awaiting `flush()` in serverless.** Events queued but never sent. Always `await flush()` before returning.
* **Putting `sk_*` in `NEXT_PUBLIC_*` / `VITE_PUBLIC_*` env vars.** Bundlers will inline the secret in the browser bundle. Use a non-public env var name.
* **Server-side events without `anonymous_id`.** Stitching breaks. Pass the id from the request.
* **Tracking the same event from both client and server.** Pick one source of truth. For `signup`, the server is canonical.


# Link anonymous → known users

Stitching pre-login activity to the post-login user, including multi-device.

A visitor lands from a Facebook ad on Tuesday. Browses for a week. Signs up on the following Monday. You want to credit that signup to the original Facebook click — *and* to know all the things they did before signing in.

This is the canonical "anonymous-to-known" stitch.

## What's happening under the hood

```
Day 1 14:00  $pageview        anon=u_abc  user=NULL    source=facebook/paid
Day 1 14:02  clicked_pricing  anon=u_abc  user=NULL    source=facebook/paid
Day 5 09:30  $pageview        anon=u_abc  user=NULL    source=direct/direct
Day 7 11:14  $identify        anon=u_abc  user=user_42                 ← here
Day 7 11:14  signup           anon=u_abc  user=user_42
Day 9 16:22  feature_used     anon=u_abc  user=user_42
```

`anonymous_id` is the same throughout. `user_id` only appears from the `$identify` onward. Historical events for `u_abc` aren't rewritten — they remain `user_id = NULL`. Stitching is a query-time join.

## Step 1 — call /v1/identify on signup or login

The single most important moment.

### From the browser

```ts
import { identify } from "@millimetric/track";

// after your auth flow resolves
identify(user.id, { email: user.email, plan: user.plan });
```

The browser SDK emits a `$identify` event and tags every subsequent `track()` with `user_id`.

### From the server (recommended for signups)

When the user is created in your DB, also POST identify:

```ts
import { init, flush } from "@millimetric/track-node";

await fetch(`${process.env.AOA_HOST}/v1/identify`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AOA_SK}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    anonymous_id: req.cookies.aid,           // threaded from the browser
    user_id: createdUser.id,
    traits: { plan: createdUser.plan, email: createdUser.email }
  })
});
```

(See [Server-side events](/recipes/server-side) for how to thread `anonymous_id`.)

## Step 2 — use the same anonymous\_id post-login

After identify, the browser SDK keeps the same `anonymous_id`. Don't generate a new one — that breaks the link.

For server-rendered apps, persist the id in a first-party cookie (see [server-side recipe](/recipes/server-side)) so it survives logout/login on the same device.

## Step 3 — stitch in queries

Three increasingly thorough patterns.

### 3a. First-touch source per user

For "where did `user_42` come from?" — find the earliest event for any `anonymous_id` they've ever been associated with.

```sql
WITH user_anons AS (
  SELECT DISTINCT user_id, anonymous_id
  FROM events
  WHERE project_id = '...'
    AND user_id IS NOT NULL
)
SELECT
  u.user_id,
  argMin(e.source, e.timestamp)   AS first_touch_source,
  argMin(e.medium, e.timestamp)   AS first_touch_medium,
  min(e.timestamp)                AS first_seen_at
FROM events e
JOIN user_anons u USING (project_id, anonymous_id)
WHERE e.project_id = '...'
GROUP BY u.user_id;
```

This works for **multi-device** users too — every device's `anonymous_id` is in `user_anons` because they all eventually called identify.

### 3b. Pre-login behaviour for one user

What was `user_42` doing before they signed up?

```sql
WITH their_anons AS (
  SELECT DISTINCT anonymous_id
  FROM events
  WHERE project_id = '...' AND user_id = 'user_42'
)
SELECT
  timestamp,
  event_name,
  source,
  medium,
  path,
  JSONExtractString(properties, 'utm_campaign') AS campaign
FROM events
WHERE project_id = '...'
  AND anonymous_id IN (SELECT anonymous_id FROM their_anons)
  AND user_id IS NULL                      -- only the pre-identify ones
ORDER BY timestamp;
```

### 3c. Time-to-conversion per user

```sql
WITH stitched AS (
  SELECT
    u.user_id,
    min(e.timestamp) AS first_seen,
    minIf(e.timestamp, e.event_name = 'signup') AS signed_up_at,
    minIf(e.timestamp, e.event_name = 'completed_checkout') AS first_purchase_at
  FROM events e
  JOIN (
    SELECT DISTINCT user_id, anonymous_id
    FROM events
    WHERE project_id = '...' AND user_id IS NOT NULL
  ) u USING (project_id, anonymous_id)
  WHERE e.project_id = '...'
  GROUP BY u.user_id
)
SELECT
  user_id,
  first_seen,
  signed_up_at,
  dateDiff('hour', first_seen, signed_up_at)        AS hours_to_signup,
  dateDiff('day',  signed_up_at, first_purchase_at) AS days_to_first_purchase
FROM stitched
WHERE signed_up_at IS NOT NULL;
```

## Multi-device

Every device the user signs in on emits its own `$identify`, linking that device's `anonymous_id` to `user_id`. The "anonymous\_id ↔ user\_id" relation becomes many-to-one over time, which is exactly what you want.

```sql
SELECT
  user_id,
  groupArray(DISTINCT anonymous_id) AS devices,
  count(DISTINCT anonymous_id) AS device_count
FROM events
WHERE project_id = '...'
  AND user_id IS NOT NULL
  AND timestamp > now() - INTERVAL 90 DAY
GROUP BY user_id
HAVING device_count > 1
ORDER BY device_count DESC;
```

## When NOT to call identify

| Moment                          | Identify?                                                             |
| ------------------------------- | --------------------------------------------------------------------- |
| Anonymous browsing              | no                                                                    |
| User signs up (creates account) | **yes**                                                               |
| User logs in (returning)        | **yes** — confirms link on this device                                |
| User logs out                   | no — keep tracking events anonymously, but don't reset `anonymous_id` |
| User switches account           | **yes** — call identify with the new `user_id`                        |

Calling identify on every page is fine. It's idempotent at the data level — you'll just have more `$identify` events.

## Common pitfalls

* **Generating a new `anonymous_id` on logout.** Severs the link. Leave it alone.
* **Calling `identify(undefined)` or `identify("")` on logout.** Ditto.
* **Calling `/v1/identify` from a `pk_*` key without `Origin` header.** Same CORS rules as `/v1/track`. Add the origin to the allowlist or use `sk_*`.
* **Expecting historical events to be rewritten.** They aren't. The stitch is *always* a query-time join. That's fine — ClickHouse joins on `(project_id, anonymous_id)` are very fast.

## See also

* [Identities](/core-concepts/identities) — concepts.
* [POST /v1/identify](/api-reference/identify) — the endpoint.
* [Sessions](/core-concepts/sessions) — entry-source per visit.
* [GDPR right-to-be-forgotten](/recipes/gdpr-delete) — what `forget` does to anonymous events.


# GDPR right-to-be-forgotten

A complete /v1/forget flow including audit-trail patterns and what doesn't get deleted.

A user emails support: "delete all my data". This recipe walks through running that request through Millimetric end-to-end, what's actually deleted, and the audit trail you should keep on your side.

## TL;DR

```bash
curl -X POST https://api.millimetric.ai/v1/forget \
  -H "Authorization: Bearer $SK_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "user_id": "user_42" }'
```

`sk_*` only. `pk_*` returns `403 forget_requires_secret_key` — a leaked browser key cannot wipe data.

## Step 1 — verify the request

You should always have *your* identity check between the email and the delete. The Worker doesn't authenticate the end user; it authenticates *you* via the `sk_*` key.

```ts
async function forgetUser(userId: string, requestedBy: string) {
  // 1. Confirm the requester is who they say they are (your auth).
  // 2. Log the request in your own audit table.
  await db.insert("forget_requests", {
    user_id: userId,
    requested_by: requestedBy,
    requested_at: new Date()
  });
  // 3. Then, and only then, call Millimetric.
}
```

## Step 2 — call /v1/forget

```ts
const res = await fetch(`${process.env.AOA_HOST}/v1/forget`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AOA_SK}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ user_id: userId })
});

if (!res.ok) {
  const err = await res.json();
  throw new Error(`forget failed: ${err.error}`);
}

return res.json();   // { ok: true, queued: true }
```

The mutation is **queued** on ClickHouse — `ALTER TABLE events DELETE WHERE project_id = ? AND user_id = ?`. Typical completion: seconds. For very large tables: minutes.

## Step 3 — also delete from related systems

`/v1/forget` only handles Millimetric. Don't forget:

* Your application database (the `users` row, sessions, content).
* Stripe / billing provider (use their `Customer.delete`).
* Email provider (Resend / Sendgrid suppression list).
* Backups, if you're being thorough — usually documented in your privacy policy as "within X days".

## What gets deleted in Millimetric

* Every row in `events` where `project_id = <your project>` AND `user_id = <user>`.

## What does **not** get deleted

| Thing                                            | Why                                                                                                                                                                         |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Anonymous events from before identify            | They have `user_id = NULL` and are indistinguishable from any other anonymous traffic. By design — the system can't tell which anonymous events belong to a now-known user. |
| Aggregated rows in `daily_rollup` and `sessions` | Aggregates, not personal data. They never had PII. Re-aggregate from raw if you want to strip a user's contribution.                                                        |
| `ip_hash` rows                                   | Already irreversible. The salt rotates daily, so within a day they're per-user, but cross-day linkability is gone.                                                          |
| Events under other projects                      | Scoped to the project owning the `sk_*` key.                                                                                                                                |

## If you also need to forget anonymous events

`/v1/forget` doesn't have `anonymous_id` support yet (PR welcome). Run the SQL directly against ClickHouse:

```sql
ALTER TABLE events DELETE
  WHERE project_id = '{your-project-id}'
    AND anonymous_id = '{the-anonymous-id-the-user-told-you}';
```

Get the `anonymous_id` from the user themselves (developer-tools snippet you ship them) or by stitching from `user_id` first:

```sql
SELECT DISTINCT anonymous_id
FROM events
WHERE project_id = '...' AND user_id = 'user_42';
```

Run forget by `user_id` first (kills post-identify events), then run the anonymous-id deletes for each one returned.

## Audit trail — the part Millimetric doesn't do for you

Today the API doesn't yet write a structured audit row for `/v1/forget` calls. **Until that's built, you keep your own audit log on the calling side**:

```sql
CREATE TABLE forget_requests (
  id            uuid primary key default gen_random_uuid(),
  user_id       text not null,
  requested_by  text not null,
  requested_at  timestamptz not null,
  millimetric_response jsonb,
  related_systems jsonb default '[]'::jsonb
);
```

Log:

* Who made the request.
* Who approved it.
* Timestamp.
* The exact response from `/v1/forget` (so you have proof of the queued mutation).
* Other systems you also wiped (Stripe customer id, etc.).

Keep this audit log indefinitely — it's *your* compliance evidence.

## Worked example

```ts
import { z } from "zod";

const Body = z.object({ user_id: z.string().min(1) });

export async function POST(req: Request) {
  // 1. Authenticate the operator. Probably an admin role on your side.
  const session = await requireAdmin(req);

  // 2. Validate input.
  const { user_id } = Body.parse(await req.json());

  // 3. Audit before action.
  const audit = await db.insert("forget_requests", {
    user_id,
    requested_by: session.userId,
    requested_at: new Date()
  });

  // 4. Millimetric.
  const mmRes = await fetch(`${process.env.AOA_HOST}/v1/forget`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AOA_SK}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ user_id })
  });
  const mmJson = await mmRes.json();
  await db.update("forget_requests", { id: audit.id }, { millimetric_response: mmJson });

  if (!mmRes.ok) return Response.json(mmJson, { status: 500 });

  // 5. Other systems.
  await stripe.customers.del(stripeCustomerIdFor(user_id));
  await db.delete("users").where("id", user_id);

  return Response.json({ ok: true });
}
```

## Errors

| Status | `error`                      | Meaning                                                              |
| ------ | ---------------------------- | -------------------------------------------------------------------- |
| 401    | `invalid_api_key`            | `sk_*` revoked or wrong project.                                     |
| 403    | `forget_requires_secret_key` | You sent a `pk_*`.                                                   |
| 400    | `invalid_payload`            | `user_id` missing or empty.                                          |
| 500    | `forget_failed`              | ClickHouse rejected the mutation. Worker logs have the trace. Retry. |

## See also

* [Privacy & retention](/core-concepts/privacy) — what's stored in the first place.
* [POST /v1/forget](/api-reference/forget) — endpoint reference.
* [Identities](/core-concepts/identities).


# Funnel & retention analysis

SQL templates for n-step funnels, day-N retention, and time-to-event.

The two queries every analytics dashboard needs eventually:

1. **Funnel.** Of users who did step 1, what % did step 2, then step 3, etc.?
2. **Retention.** Of users who first did X on day D, what % were still doing X on day D+N?

Millimetric doesn't ship a hosted "funnel builder" UI yet — but the queries are short, the data is in ClickHouse, and you can run them directly via your ClickHouse credentials or wrap them in a route in your own app.

## A funnel template

```sql
WITH steps AS (
  SELECT
    anonymous_id,
    minIf(timestamp, event_name = '$pageview')         AS s1_landed,
    minIf(timestamp, event_name = 'clicked_cta')       AS s2_clicked,
    minIf(timestamp, event_name = 'viewed_signup_form') AS s3_form,
    minIf(timestamp, event_name = 'signup')            AS s4_signup,
    minIf(timestamp, event_name = 'activated')         AS s5_activated
  FROM events
  WHERE project_id = '...'
    AND timestamp BETWEEN '2026-05-01' AND '2026-05-17'
  GROUP BY anonymous_id
)
SELECT
  countIf(s1_landed   IS NOT NULL)                                            AS landed,
  countIf(s2_clicked  IS NOT NULL AND s2_clicked  >= s1_landed)               AS clicked,
  countIf(s3_form     IS NOT NULL AND s3_form     >= s2_clicked)              AS viewed_form,
  countIf(s4_signup   IS NOT NULL AND s4_signup   >= s3_form)                 AS signed_up,
  countIf(s5_activated IS NOT NULL AND s5_activated >= s4_signup)             AS activated,

  round(100.0 * countIf(s2_clicked  IS NOT NULL AND s2_clicked  >= s1_landed) / countIf(s1_landed IS NOT NULL), 2) AS pct_to_click,
  round(100.0 * countIf(s5_activated IS NOT NULL AND s5_activated >= s4_signup) / countIf(s1_landed IS NOT NULL), 2) AS pct_landed_to_activated
FROM steps;
```

Key bits:

* `minIf(timestamp, event_name = …)` returns the **first** time each step happened per user.
* `s_n >= s_{n-1}` enforces ordering — a user who signed up *before* viewing the form doesn't count.
* All steps within a single window (`BETWEEN …`) for clean cohort math.

### Funnel by traffic source

Group the same logic by entry source:

```sql
WITH steps AS (
  SELECT
    anonymous_id,
    argMin(source, timestamp)  AS first_source,
    argMin(medium, timestamp)  AS first_medium,
    minIf(timestamp, event_name = '$pageview') AS s1,
    minIf(timestamp, event_name = 'signup')    AS s4
  FROM events
  WHERE project_id = '...'
    AND timestamp > now() - INTERVAL 30 DAY
  GROUP BY anonymous_id
)
SELECT
  first_source,
  first_medium,
  countIf(s1 IS NOT NULL)              AS landed,
  countIf(s4 IS NOT NULL AND s4 >= s1) AS signed_up,
  round(100.0 * countIf(s4 IS NOT NULL AND s4 >= s1) / countIf(s1 IS NOT NULL), 2) AS pct
FROM steps
GROUP BY first_source, first_medium
ORDER BY signed_up DESC;
```

Drops out as a per-channel conversion rate. Combine with [marketing-attribution](/recipes/marketing-attribution) for revenue.

## Day-N retention

For users who first did `X` on day D, what fraction did `X` on day D+N?

```sql
WITH cohort AS (
  SELECT
    user_id,
    toDate(min(timestamp)) AS cohort_day
  FROM events
  WHERE project_id = '...'
    AND event_name = 'signup'
  GROUP BY user_id
),
returns AS (
  SELECT
    user_id,
    toDate(timestamp) AS active_day
  FROM events
  WHERE project_id = '...'
    AND user_id IS NOT NULL
    AND event_name IN ('feature_used', '$pageview')   -- "active" definition
)
SELECT
  c.cohort_day,
  dateDiff('day', c.cohort_day, r.active_day) AS day_n,
  uniq(c.user_id) AS users
FROM cohort c
JOIN returns r USING user_id
WHERE r.active_day >= c.cohort_day
  AND r.active_day < c.cohort_day + INTERVAL 30 DAY
GROUP BY c.cohort_day, day_n
ORDER BY c.cohort_day, day_n;
```

→ pivots to:

```
cohort_day  | day0 | day1 | day7 | day14 | day30
2026-05-01  |  100 |   62 |   38 |    24 |    18
2026-05-02  |  118 |   71 |   44 |    27 |    19
...
```

Day-0 is your cohort size. Each subsequent day is the % of that cohort active that day.

### "Stickiness" — DAU / MAU

```sql
SELECT
  toDate(timestamp) AS day,
  uniq(user_id)     AS dau,
  (
    SELECT uniq(user_id)
    FROM events
    WHERE project_id = '...'
      AND user_id IS NOT NULL
      AND timestamp BETWEEN day - INTERVAL 30 DAY AND day
  )                 AS mau,
  round(100.0 * uniq(user_id) / mau, 2) AS dau_mau_pct
FROM events
WHERE project_id = '...'
  AND user_id IS NOT NULL
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY day
ORDER BY day;
```

A DAU/MAU above 20% is good; above 50% is excellent.

## Time-to-event

How long from `signup` to first `purchase`?

```sql
WITH events_per_user AS (
  SELECT
    user_id,
    minIf(timestamp, event_name = 'signup')              AS signed_up,
    minIf(timestamp, event_name = 'completed_checkout')  AS first_purchase
  FROM events
  WHERE project_id = '...'
    AND user_id IS NOT NULL
  GROUP BY user_id
)
SELECT
  user_id,
  signed_up,
  first_purchase,
  dateDiff('hour', signed_up, first_purchase) AS hours_to_first_purchase
FROM events_per_user
WHERE first_purchase IS NOT NULL
ORDER BY hours_to_first_purchase;
```

### Distribution

```sql
SELECT
  if(hours <= 1,   '0–1h',
  if(hours <= 24,  '1–24h',
  if(hours <= 168, '1–7d',
  if(hours <= 720, '7–30d', '>30d')))) AS bucket,
  count() AS users
FROM (
  SELECT
    dateDiff('hour',
      minIf(timestamp, event_name = 'signup'),
      minIf(timestamp, event_name = 'completed_checkout')
    ) AS hours
  FROM events
  WHERE project_id = '...'
  GROUP BY user_id
  HAVING hours > 0
)
GROUP BY bucket
ORDER BY min(hours);
```

## Power-user definition

Users in the top-decile of activity over 30 days:

```sql
WITH activity AS (
  SELECT
    user_id,
    count() AS event_count
  FROM events
  WHERE project_id = '...'
    AND user_id IS NOT NULL
    AND timestamp > now() - INTERVAL 30 DAY
  GROUP BY user_id
)
SELECT user_id, event_count
FROM activity
WHERE event_count >= (SELECT quantile(0.9)(event_count) FROM activity)
ORDER BY event_count DESC;
```

Use that cohort to drive A/B tests, surveys, or "which feature predicts retention" analyses.

## Wrapping these in your app

You can hit ClickHouse directly with your `CLICKHOUSE_*` credentials, but most teams build a thin server route per saved query and read from the browser:

```ts
// app/api/analytics/funnel/route.ts
import { NextResponse } from "next/server";
import { ClickHouse } from "@clickhouse/client";

const ch = new ClickHouse({
  url: process.env.CLICKHOUSE_URL!,
  username: process.env.CLICKHOUSE_USER!,
  password: process.env.CLICKHOUSE_PASSWORD!
});

export async function GET() {
  const result = await ch.query({
    query: `WITH steps AS (...) SELECT ... FROM steps`,
    format: "JSONEachRow"
  });
  return NextResponse.json(await result.json());
}
```

Cache aggressively — these queries get expensive at scale, and the answers don't change second-to-second.

## See also

* [Events](/core-concepts/events), [Sessions](/core-concepts/sessions).
* [GET /v1/stats](/api-reference/stats) — for simple aggregations without writing SQL.
* [Architecture](/architecture) — where the data physically lives.


# Event schema

Full Zod schemas, ClickHouse columns, and what's required vs optional.

The single source of truth is [`packages/schema/src/index.ts`](https://github.com/) — the Zod schemas the API validates against and the SDKs export. This page is a human-readable mirror.

## TrackEventInput — `POST /v1/track` body

```ts
{
  event: string,                     // required, 1–128 chars
  event_id?: string,                 // 1–128 chars
  timestamp?: string,                // ISO 8601
  anonymous_id?: string,             // 1–128 chars
  user_id?: string,                  // 1–256 chars
  session_id?: string,               // 1–128 chars

  url?: string,                      // valid URL, ≤ 2048 chars
  path?: string,                     // ≤ 2048 chars
  referrer?: string,                 // ≤ 2048 chars

  properties?: Record<string, unknown>   // JSON-stringified ≤ 8 KB
}
```

| Field          | Required | Validation         | Notes                                                             |
| -------------- | -------- | ------------------ | ----------------------------------------------------------------- |
| `event`        | **yes**  | 1–128 chars        | Use snake\_case. `$`-prefix reserved for system events.           |
| `event_id`     | no       | 1–128 chars        | Idempotency / join key. No server-side dedup yet.                 |
| `timestamp`    | no       | ISO 8601           | Server clock if omitted.                                          |
| `anonymous_id` | no       | 1–128 chars        | Caller-supplied UUID. Server fabricates if omitted.               |
| `user_id`      | no       | 1–256 chars        | Set after `/v1/identify`.                                         |
| `session_id`   | no       | 1–128 chars        | Auto-derived if omitted. See [Sessions](/core-concepts/sessions). |
| `url`          | no       | URL, ≤ 2048        | Classifier reads this.                                            |
| `path`         | no       | ≤ 2048             | Browser SDK fills this.                                           |
| `referrer`     | no       | ≤ 2048             | Classifier reads this.                                            |
| `properties`   | no       | ≤ 8 KB stringified | Free-form JSON.                                                   |

## BatchInput — `POST /v1/batch` body

```ts
{
  events: TrackEventInput[]   // 1–1000 events
}
```

## IdentifyInput — `POST /v1/identify` body

```ts
{
  anonymous_id: string,        // required, 1–128 chars
  user_id: string,             // required, 1–256 chars
  traits?: Record<string, unknown>
}
```

## ForgetInput — `POST /v1/forget` body

```ts
{ user_id: string }            // required, 1–256 chars
```

## ClassifiedSource — what the classifier returns

```ts
{
  source: string,                                  // "facebook", "google", "direct", ...
  medium: string,                                  // "paid", "organic", "social", "direct", ...
  campaign?: string,                               // utm_campaign
  confidence: "low" | "medium" | "high",
  rule_id: string                                  // which classifier rule fired
}
```

The full rule cascade is in [Attribution](/core-concepts/attribution).

## EventRow — what gets inserted into ClickHouse

```sql
project_id          UUID
timestamp           DateTime64(3)
event_id            String
event_name          String
anonymous_id        String
user_id             Nullable(String)
session_id          String

source              LowCardinality(String)
medium              LowCardinality(String)
campaign            Nullable(String)
source_confidence   LowCardinality(String)
source_rule_id      LowCardinality(String)

referrer            Nullable(String)
url                 Nullable(String)
path                Nullable(String)
country             LowCardinality(String)
device_type         LowCardinality(String)
browser             LowCardinality(String)
os                  LowCardinality(String)
ip_hash             FixedString(16)
properties          String              -- JSON-encoded
```

Partitioned by `toYYYYMM(timestamp)`, ordered by `(project_id, timestamp, event_id)`.

## Materialised views

### `daily_rollup`

Aggregates by `(project_id, day, event_name, source, medium, country, device_type)`. Powers `/v1/stats`.

| Column    | Type                                                        |
| --------- | ----------------------------------------------------------- |
| `events`  | `SimpleAggregateFunction(sum, UInt64)`                      |
| `uniques` | `AggregateFunction(uniq, String)` (HLL over `anonymous_id`) |

### `sessions`

One row per `(project_id, session_id, anonymous_id)`. Powers `/v1/sources` and revenue attribution. Schema in [Sessions](/core-concepts/sessions#the-sessions-materialised-view).

## What's *not* in the schema

* **Email, name, address.** Don't send PII.
* **Raw IP.** Sent in the request, never persisted — we store `HMAC(ip, IP_SALT || UTC_date)` only.
* **City-level geo.** Country only. We use Cloudflare's `cf-ipcountry`.
* **Cookies.** None set by us.

## Validation errors

A failed Zod parse returns `400 invalid_payload` with the flattened error tree. Example:

```json
{
  "error": "invalid_payload",
  "details": {
    "fieldErrors": {
      "event": ["Expected string, received undefined"],
      "url": ["Invalid url"]
    }
  }
}
```

## See also

* [Events](/core-concepts/events) — narrative version.
* [Properties](/core-concepts/properties) — what to put in `properties`.
* [Errors](/reference/errors) — all error codes in one table.
* [POST /v1/track](/api-reference/track) — the endpoint.


# Event & property naming

A short, opinionated style guide for event and property names.

There's no schema enforcement on event or property names — Millimetric will store whatever you send. But analytics taxonomies rot fast unless you have a convention. Here's ours.

## Event names

**snake\_case, past tense, scoped to the actor.**

```
✅  signup
✅  trial_started
✅  checkout_completed
✅  invite_sent
✅  agent_task_completed

❌  Sign Up
❌  signing-up
❌  signupCompleted
❌  Signup Successful
```

### `$`-prefix is reserved

System events emitted by the SDK or the server start with `$`. Don't define your own `$`-events; they're indistinguishable from system ones in the dashboard.

\| `$pageview` | Browser SDK on load and SPA navigation. | | `$identify` | Server, on `POST /v1/identify`. |

### Verb in the past tense

`signup` is fine, but `signed_up` is *better*. Past tense reads naturally in funnel queries: "users who **signed\_up** then **subscribed**".

That said, the existing convention in this codebase uses bare nouns/short verbs (`signup`, `purchase`, `clicked_pricing`) — both work. Pick one and stick to it.

### Don't pack multiple events into a name

```
❌  button_clicked_pricing_page_hero
❌  user_logged_in_via_google_oauth

✅  clicked_cta             (with properties: { page: "pricing", section: "hero" })
✅  logged_in               (with properties: { method: "google" })
```

Property cardinality is cheap. Event-name cardinality is what kills your dashboards.

### Roughly 30–50 events is plenty

If you have 200 distinct event names, you almost certainly should have fewer events with more properties.

## Property names

**snake\_case. Type the value. Include units where ambiguous.**

```
✅  amount_cents: 4900
✅  currency: "usd"
✅  duration_ms: 1240
✅  is_first_purchase: true
✅  user_role: "admin"

❌  Amount: "$49.00"
❌  duration: 1.24
❌  firstPurchase: "yes"
❌  Role: "Admin"
```

### Booleans named as questions

`is_paid`, `has_premium`, `was_invited`, `did_complete_onboarding`. Reads nicely in `WHERE` clauses.

### Units on numbers, always

`duration_ms`, `amount_cents`, `latency_ms`, `size_bytes`, `weight_kg`. Pick a unit per metric and never mix.

### Money in cents

Or any fixed-precision integer. **Never floats** for revenue. `amount_cents: 4900`, plus `currency: "usd"`. Always together.

### `$`-prefix on properties means "ambient context"

The browser SDK adds `$viewport_w`, `$viewport_h`, `$language`, `$timezone`. Following that convention for your own ambient context (`$session_user_role`, `$build_version`) keeps event-specific properties visually distinct from cross-cutting ones.

## Conventions tools rely on

If you set these with the listed names, dashboards and MCP tools will Just Work.

| Property                                                              | Used for                               |
| --------------------------------------------------------------------- | -------------------------------------- |
| `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` | Classifier (also reads them off `url`) |
| `fbclid`, `gclid`, `ttclid`, `msclkid`, `li_fat_id`                   | Classifier                             |
| `amount_cents` + `currency`                                           | Revenue dashboards (roadmap)           |
| `experiment_id` + `variant`                                           | A/B test analysis (roadmap)            |
| `plan` (string)                                                       | Cohort filtering on user-tier          |
| `path` (on the event itself, not in properties)                       | Top-paths breakdown                    |

## A small starter taxonomy

Steal this for a SaaS product:

| Event                   | When                    | Key properties                                       |
| ----------------------- | ----------------------- | ---------------------------------------------------- |
| `$pageview`             | every navigation        | `path`, `url`, `referrer` (auto-captured)            |
| `signup`                | account created         | `plan`, `referral_code`, `invited`                   |
| `trial_started`         | trial begins            | `plan`, `trial_days`                                 |
| `subscription_started`  | first paid charge       | `plan`, `amount_cents`, `currency`, `billing_period` |
| `subscription_changed`  | upgrade/downgrade       | `from_plan`, `to_plan`, `amount_cents`, `currency`   |
| `feature_used`          | a notable in-app action | `feature`, `result`                                  |
| `invite_sent`           | user invites a teammate | `count`                                              |
| `support_ticket_opened` | help requested          | `category`, `priority`                               |
| `error_shown`           | user-facing error       | `error_code`, `http_status`                          |
| `account_deleted`       | farewell                | `reason`                                             |

For e-commerce, swap `signup`/`trial_*`/`subscription_*` for `viewed_product`, `added_to_cart`, `removed_from_cart`, `started_checkout`, `completed_checkout`, `refunded`. See the [E-commerce recipe](/recipes/ecommerce).

## Renaming is a chore — pick now

There's no rename API. Renames happen by:

1. Start emitting the new name.
2. Wait for the retention window to pass (default 90 days).
3. Old events expire, new events have the new name.

Or query both names with a `CASE WHEN` until the old one rolls off. Easiest by far is to pick a convention before you start and write it down somewhere your team will read it.


# Errors

Every error code the API can return, what triggers it, and how to fix it.

Every Millimetric error is a JSON response with a stable string `error` field and an HTTP status code. The string is the contract — the human-readable message is *not*.

```json
{
  "error": "invalid_payload",
  "details": { "fieldErrors": { "event": ["Required"] } }
}
```

## Auth errors (401 / 403)

| Status | `error`                      | Trigger                                                                 | Fix                                                                             |
| ------ | ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| 401    | `missing_bearer_token`       | No `Authorization` header.                                              | Send `Authorization: Bearer {key}`.                                             |
| 401    | `malformed_api_key`          | Key doesn't match \`(pk                                                 | sk                                                                              |
| 401    | `invalid_api_key`            | No matching key in Supabase, or HMAC doesn't verify.                    | Mint a new key. Old one was revoked or never existed.                           |
| 401    | `key_kind_mismatch`          | Stored key has a different kind than the prefix claims.                 | Re-mint. Likely a copy-paste from another row.                                  |
| 401    | `invalid_session`            | Admin endpoint: user JWT failed Supabase validation.                    | Sign in again.                                                                  |
| 403    | `origin_not_allowed`         | `pk_*` key from an origin not in the project's `allowed_origins`.       | Add the origin in the dashboard, or use `sk_*` server-side.                     |
| 403    | `insufficient_scope`         | Read endpoint called with `pk_*`/`sk_*`, or write endpoint with `rk_*`. | Use a key with the right scope: `pk_/sk_` for ingest, `rk_` for read.           |
| 403    | `forget_requires_secret_key` | `/v1/forget` called with `pk_*`.                                        | Use `sk_*`. Browser keys are explicitly rejected to prevent leak-induced wipes. |

## Validation errors (400)

| Status | `error`            | Trigger                                                                             | Fix                                                                                            |
| ------ | ------------------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 400    | `invalid_payload`  | Body failed Zod parse. `details.fieldErrors` is the flattened tree.                 | Inspect `details`. Common causes: `event` missing, `properties` > 8 KB, malformed `url`.       |
| 400    | `invalid_params`   | Query string failed validation.                                                     | Check `from`/`to` are ISO-8601, `metric` is one of `count`/`uniques`.                          |
| 400    | `invalid_group_by` | Unknown column passed to `/v1/stats?group_by=`. Response includes the allowed list. | Use only: `event_name`, `source`, `medium`, `country`, `device_type`, `browser`, `os`, `path`. |

## Rate limits (429)

| Status | `error`        | Trigger                                                                              | Fix                                                                                          |
| ------ | -------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| 429    | `rate_limited` | Token bucket exhausted for this `(project_id, route)`. `Retry-After` header present. | Back off `Retry-After` seconds, then retry. For sustained throughput, switch to `/v1/batch`. |

Limits:

| Endpoint         | Refill | Burst |
| ---------------- | ------ | ----- |
| `POST /v1/track` | 50/sec | 200   |
| `POST /v1/batch` | 5/sec  | 20    |

(Per-Worker-instance today. Move to a Durable Object if needed.)

## Server errors (5xx)

| Status | `error`           | Trigger                                          | Fix                                                           |
| ------ | ----------------- | ------------------------------------------------ | ------------------------------------------------------------- |
| 500    | `internal_error`  | Unhandled exception. Worker logs have the trace. | Retry once with jitter. If persistent, check `wrangler tail`. |
| 500    | `forget_failed`   | ClickHouse rejected the `ALTER TABLE … DELETE`.  | Worker logs. Likely a transient ClickHouse issue.             |
| 502    | `upstream_failed` | Couldn't reach ClickHouse / Supabase.            | Same as above — retry.                                        |

## MCP-specific errors (JSON-RPC)

The MCP transport uses JSON-RPC error envelopes:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32000,
    "message": "insufficient_scope",
    "data": { "required": "ingest", "got": "read" }
  }
}
```

| Code   | Meaning                                                 |
| ------ | ------------------------------------------------------- |
| -32600 | `invalid_request` — body not a valid JSON-RPC envelope. |
| -32601 | `method_not_found` — unknown method.                    |
| -32602 | `unknown_tool` / `unknown_resource` / `unhandled_tool`. |
| -32000 | `tool_failed` (server error) / `insufficient_scope`.    |

## Retry guidance

| Error class                           | Retry? | How?                                                                  |
| ------------------------------------- | ------ | --------------------------------------------------------------------- |
| 4xx (validation, auth, scope, origin) | **No** | The payload is wrong. Fix it.                                         |
| 429 `rate_limited`                    | Yes    | Honour `Retry-After`. Use `/v1/batch` for sustained writes.           |
| 5xx                                   | Yes    | Exponential backoff. The Node SDK does 100/200/400/800 ms by default. |
| Network errors                        | Yes    | Same as 5xx.                                                          |

## Worker logs

The HTTP response only ever returns a short `error` code. To see the full traceback:

```bash
pnpm --filter @millimetric/api wrangler tail
# or, in local dev:
pnpm dev:api
```

Every error logs the request path, the project (when known), and the failure point.

## See also

* [API overview](/api-reference/overview) — base URL, content type, idempotency.
* [Rate limits](/reference/rate-limits) — full table.
* [API keys](/core-concepts/api-keys) — key kinds and scopes.


# Rate limits

Per-project, per-route token buckets — and how to stay under them.

Rate limits are **per project, per route**, enforced by an in-memory token bucket on the Cloudflare Worker. There's no per-key or per-IP limit on top.

## The numbers

| Endpoint            | Refill rate                          | Burst capacity |
| ------------------- | ------------------------------------ | -------------- |
| `POST /v1/track`    | 50 / sec                             | 200            |
| `POST /v1/batch`    | 5 / sec                              | 20             |
| `GET /v1/query`     | unlimited                            | n/a            |
| `GET /v1/stats`     | unlimited                            | n/a            |
| `GET /v1/sources`   | unlimited                            | n/a            |
| `POST /v1/identify` | unlimited                            | n/a            |
| `POST /v1/forget`   | unlimited                            | n/a            |
| `POST /mcp`         | inherits the underlying tool's limit | —              |

When a bucket is empty, the Worker returns:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json

{ "error": "rate_limited", "retry_after_s": 1 }
```

## Why these numbers

The headline workload is browser SDKs flushing buffered events. A typical session does < 10 events. 50/sec sustained per project is enough headroom for \~5,000 concurrent active visitors *per project*.

`/v1/batch` is rate-limited more tightly because each call delivers up to 1000 events — 5/sec × 1000 = 5,000 events/sec sustained, 20-call burst = 20,000 events instantly.

## How to stay under

### Use the SDK

The [browser SDK](/sdks/browser) batches automatically (every 20 events or 2 seconds, whichever comes first) and flushes on `pagehide` via `sendBeacon`. You'll never hit the limits from a browser unless you're calling `track()` in a tight loop.

The [Node SDK](/sdks/node) is single-event-per-call by default (`flushAt: 1`). For high-volume servers, set `flushAt: 50` and the SDK switches to `/v1/batch`.

```ts
init({ key, host, flushAt: 50, flushIntervalMs: 2000 });
```

### Backfill via /v1/batch

Backfilling 100k events through `/v1/track` would take \~33 minutes wall-clock at 50/sec. Through `/v1/batch` with chunks of 1000 events:

```ts
for (let i = 0; i < events.length; i += 1000) {
  const chunk = events.slice(i, i + 1000);
  await fetch("/v1/batch", { /* ... */ body: JSON.stringify({ events: chunk }) });
  await new Promise(r => setTimeout(r, 200));   // ~5/sec
}
```

100k events in 100 batch calls × 200 ms = 20 seconds.

### Spread across projects

Limits are per project. If you have multiple projects in one organisation, traffic to each gets its own bucket.

## Implementation note

The bucket is currently **in-memory per Worker instance**. Cloudflare scales Workers horizontally, so the *effective* limit for a project at very high traffic is roughly `(rate × N_instances)`. That over-counts in practice but it's the right ceiling to plan against.

For multi-tenant fairness at scale, swap the in-memory bucket for a Durable Object — see `apps/api/src/auth/rateLimit.ts`. That's a one-file change when needed; it isn't yet because the current setup hasn't been the bottleneck.

## Backoff strategy

Honour `Retry-After`:

```ts
async function trackWithRetry(payload, retries = 3) {
  const res = await fetch("/v1/track", { /* ... */ body: JSON.stringify(payload) });
  if (res.status === 429 && retries > 0) {
    const wait = Number(res.headers.get("Retry-After") ?? "1") * 1000;
    await new Promise(r => setTimeout(r, wait));
    return trackWithRetry(payload, retries - 1);
  }
  return res;
}
```

The Node SDK does this automatically on `5xx`. It does **not** automatically retry `429` (since the bucket is per-project, retries from many concurrent requests just keep the bucket empty). For `429`, the SDK throws and lets you decide.

## See also

* [Errors](/reference/errors) — `rate_limited` and friends.
* [POST /v1/batch](/api-reference/batch) — the cheap way to send many events.
* [Node SDK](/sdks/node) — batching & retry behaviour.


