> For the complete documentation index, see [llms.txt](https://docs.millimetric.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.millimetric.ai/core-concepts/events.md).

# Events

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.md).

## 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.md), 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.md)) 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.md). 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.md). 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.md).
* **Daily aggregates** — `daily_rollup` materialised view. Queried by [`/v1/stats`](/api-reference/stats.md).
* **Sessions** — `sessions` materialised view. Used for entry-source attribution and the FB social-vs-paid split in [`/v1/sources`](/api-reference/sources.md).

## See also

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


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.millimetric.ai/core-concepts/events.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
