# Pug Docs > Documentation for Pug: open-source product analytics. SDKs, HTTP API, and self-hosting. Index: https://docs.pug.sh/llms.txt # Integrate Pug > Event analytics and user profiles you can wire into any app - install an SDK, POST straight to the API, or self-host the whole stack. Source: https://docs.pug.sh/ | Markdown: https://docs.pug.sh/index.md Pug is open-source product analytics. Capture events, identify the people behind them, and query the result back out - from a browser, a mobile app, your backend, or any language that can POST JSON. Run it on Pug Cloud or [self-host the entire stack](/self-hosting). Custom event names work with no setup. Beyond those, Pug ships **[127 typed events](/reference/well-known-events)** across 19 families (commerce, auth, billing, media, support), each with a validated property schema, so `purchase` and `signup` mean the same thing in your data as they do in everyone else's. Capture is [cookieless by default](/sdks#tracking-consent): events flow from the first hit, and nothing is stored on the device until the reader consents.
Quickstart Install with AI Install the Web SDK
## Initialize, then track The whole integration, on every platform. Pick yours - the choice follows you into the [SDK reference](/sdks). ### Web ```ts import { init, track } from '@pug-sh/browser' init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY' }) track('page_view') ``` ### Flutter ```dart import 'package:pug_flutter/pug_flutter.dart'; await Pug.init( 'YOUR_PROJECT_ID', const PugOptions(apiKey: 'pub_YOUR_PUBLIC_KEY'), ); Pug.track('page_view'); ``` ### Node ```ts import { Pug } from '@pug-sh/node' // Server-side - authenticate with your private key. const pug = new Pug({ apiKey: process.env.PUG_PRIVATE_KEY! }) // A server has no ambient user - name the distinctId. pug.track('user-123', 'page_view') ``` ### HTTP API ```bash # No SDK? POST events to the Connect RPC ingest endpoint. # See /sdks for the full request shape. curl -X POST https://api.pugs.dev/sdk.events.v1.EventsService/BatchCreate \ -H "x-api-key: pub_YOUR_PUBLIC_KEY" \ -H "Connect-Protocol-Version: 1" \ -H "Content-Type: application/json" \ -d '{"events":[{"eventId":"01J9...","kind":"page_view","distinctId":"anon-1","sessionId":"01J9...","occurTime":"2026-06-05T10:00:00Z"}]}' ``` ## Ways to integrate - **Web app** - Install the Web SDK (`@pug-sh/browser`) - auto-track page views and clicks, identify users on sign-in. [Quickstart](/get-started) - **Mobile app** - Typed event tracking, screen and lifecycle auto-tracking, and identity for Flutter apps. [Flutter setup](/sdks?platform=flutter) - **Backend or any language** - Send events and read analytics from Node with `@pug-sh/node` - or POST straight to the Connect RPC API from anywhere else. [Node SDK](/sdks?platform=node) - **Query your data** - Read insights and profiles programmatically with your private key, via the Node SDK or the Connect API. [API reference](/api) - **Self-host** - Run the full stack - Postgres, ClickHouse, NATS, Dragonfly and the Go workers - on your own infrastructure. [Self-hosting guide](/self-hosting) ## How it works Events flow from your app through an async pipeline into analytics storage. The same data powers Live, Insights, and Profiles. Ingestion is near-real-time: events appear in **Live** within seconds and in **Insights** within the same minute. See [Core concepts](/get-started/concepts) for the full model. ## Jump straight in ### Track ```ts import { track } from '@pug-sh/browser' track('button_clicked', { label: 'Sign up' }) track('purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' }, { immediate: true }) ``` ### Identify ```ts import { identify, reset } from '@pug-sh/browser' // On sign-in - merges prior anonymous activity into the profile. await identify('user-123', { email: 'user@example.com', plan: 'pro' }) // On sign-out. reset() ``` ## Start here - **Get started** - [Quickstart](/get-started) | [Core concepts](/get-started/concepts) | [Install the Web SDK](/sdks#install) | [Authentication](/get-started/authentication) - **Build** - [Track events](/sdks#track-events) | [Identity and sessions](/sdks#identify-and-sessions) | [Consent and privacy](/sdks#consent-and-privacy) | [Flutter](/sdks?platform=flutter) | [Node](/sdks?platform=node) | [HTTP](/sdks?platform=http) - **Reference** - [API reference](/api) | [Well-known events](/reference/well-known-events) | [Auto-properties](/reference/auto-properties) | [Self-hosting](/self-hosting) | [GitHub](https://github.com/pug-sh) # API reference > Call every Pug endpoint with plain JSON over HTTP POST. Base URLs, request format, auth modes and the Connect error model. Source: https://docs.pug.sh/api/ | Markdown: https://docs.pug.sh/api/index.md **You can call every endpoint with plain JSON over HTTP POST**: two headers (`Content-Type: application/json` and `Connect-Protocol-Version: 1`) plus a JSON-encoded body. No client library or codegen is required; see [HTTP transport](/sdks) for a curl-ready recipe. Under the hood the API is built on **Connect RPC** - a single set of handlers that speaks the Connect, gRPC, and gRPC-Web protocols over the same HTTP endpoints, on both HTTP/1.1 and HTTP/2. Generated clients are available for any language with Buf support, but the plain-JSON path above is all you need. ## Base URL | Environment | Base URL | |-------------|----------| | Production | `https://api.pugs.dev` | | Self-hosted / local | `http://localhost:3000` | The server port for self-hosted deployments defaults to `3000` (configurable via `PUG_SERVER_PORT`). ## Request format Every Connect RPC endpoint is reachable at: ``` POST /{package}.{Service}/{Method} ``` For example: ``` POST /sdk.events.v1.EventsService/BatchCreate POST /sdk.profiles.v1.ProfilesSDKService/Identify POST /shared.insights.v1.InsightsService/Query ``` For Connect JSON (no generated client needed), send these headers on every request: ```http Content-Type: application/json Connect-Protocol-Version: 1 ``` Add the appropriate auth header (see below) and POST a JSON-encoded request body. ## Authentication Auth is enforced at the HTTP middleware layer, before any handler runs. The mode depends on the proto package. For obtaining and rotating keys, see [Authentication](/get-started/authentication). ### SDK key (`sdk.*` services) Send your API key in the `x-api-key` header: ```http x-api-key: pub_... # or prv_... ``` Two key types are accepted: | Key prefix | Access | |------------|--------| | `pub_...` | Write / ingest only. Safe to embed in browser code via the Web SDK. | | `prv_...` | Full read-write. Server-side and trusted environments only. | No `Authorization` header or `x-project-id` is required for SDK auth - the project is resolved directly from the key. Beacon requests (which cannot set headers) may pass a public key as the `api_key` query parameter. Private keys are rejected on the query parameter path. ### JWT (`dashboard.*` services) ```http Authorization: Bearer ``` Optionally add `x-project-id: ` to scope the request to a specific project (the server verifies org membership). JWT auth is issued by `public.auth.v1.AuthService`. ### Dual: private key or JWT (`shared.*` services) Shared services are accessible from both server-side integrations and the dashboard. The server checks `x-api-key` first; if absent, it falls back to `Authorization: Bearer`. Only **private keys** (`prv_...`) are accepted via this path - public keys are rejected. ```http x-api-key: prv_... # server-side path # -- or -- Authorization: Bearer # dashboard path ``` ### Public (no auth) Two services require no credential: - `public.auth.v1.AuthService`: sign-in, magic link, OAuth. - `public.dashboards.v1.SharedDashboardsService`: reads a publicly-shared dashboard. Authorization comes from the unguessable **share token** carried in the request, so treat that token as a bearer credential: anyone holding it can read the dashboard. ### Auth matrix | Proto package | Auth mode | Header | |---------------|-----------|--------| | `sdk.*` | SDK key | `x-api-key: pub_... \| prv_...` | | `dashboard.*` | JWT | `Authorization: Bearer ` | | `shared.*` | Dual (private key or JWT) | `x-api-key: prv_...` or `Authorization: Bearer ` | | `public.*` | None | - (share token in the request body, where applicable) | ## Error model All errors follow the Connect error envelope. For Connect JSON the response body is: ```json { "code": "invalid_argument", "message": "events[0].name: value is required" } ``` Standard codes returned by the API (HTTP status is the Connect-protocol mapping): | Code | HTTP status | Meaning | |------|------|---------| | `unauthenticated` | 401 | Missing, malformed, or expired credential | | `permission_denied` | 403 | Valid credential, insufficient access for this operation | | `invalid_argument` | 400 | Request failed protovalidate: fix the named field and retry | | `not_found` | 404 | Resource does not exist or is not visible to the caller | | `already_exists` | 409 | Attempted to create a resource that already exists | | `failed_precondition` | 412 | Operation rejected due to current system state | | `resource_exhausted` | 429 | Rate limit exceeded: back off and retry | | `internal` | 500 | Server-side error: retry with exponential backoff | Connect errors also carry `google.rpc.ErrorInfo` details (reason + domain) and a `google.rpc.RequestInfo` with the correlation id, useful for support requests. ## Services at a glance | Service | Proto name | Auth | Docs | |---------|-----------|------|------| | Events (ingest) | `sdk.events.v1.EventsService` | SDK key | [Events](/api/events) | | Profiles (SDK) | `sdk.profiles.v1.ProfilesSDKService` | SDK key | [Profiles](/api/profiles) | | Profiles (read/manage) | `shared.profiles.v1.ProfilesService` | Dual | [Profiles](/api/profiles) | | Insights | `shared.insights.v1.InsightsService` | Dual | [Insights](/api/insights) | | Activity | `shared.activity.v1.ActivityService` | Dual | - | | Orgs | `dashboard.orgs.v1.OrgsService` | JWT | - | | Projects | `dashboard.projects.v1.ProjectsService` | JWT | - | | Dashboards | `dashboard.dashboards.v1.DashboardsService` | JWT | - | | Customers | `dashboard.customers.v1.CustomersService` | JWT | - | | Auth | `public.auth.v1.AuthService` | None | - | | Shared dashboards | `public.dashboards.v1.SharedDashboardsService` | None (share token) | - | Services showing **-** in the Docs column are dashboard or internal services not covered in this reference. `sdk.devices.v1.DevicesService` handles push-notification device registration and is likewise out of scope. The read-only half of the `shared.*` services is also served as Model Context Protocol tools at `POST /mcp`, for agents rather than generated clients - see [MCP server](/mcp). For the complete method index see [RPC services](/reference/rpc-services). ## Next steps - [Events](/api/events): ingest events with `sdk.events.v1.EventsService/BatchCreate` - [Profiles](/api/profiles): identify users and read/manage profiles - [Insights](/api/insights): run analytics queries programmatically - [MCP server](/mcp): the same read APIs as tools for an AI agent - [HTTP transport](/sdks): curl-ready plain-JSON recipes - [RPC services](/reference/rpc-services): full package and method map # Events API > Ingest event batches with sdk.events.v1.EventsService/BatchCreate - full request schema, property wrapping and validation rules. Source: https://docs.pug.sh/api/events/ | Markdown: https://docs.pug.sh/api/events/index.md Ingest events from SDKs and server-side integrations using `sdk.events.v1.EventsService/BatchCreate`. **Auth:** `x-api-key` header (public key `pub_...` or private key `prv_...`). No `Authorization` header or `x-project-id` is required - the project is resolved from the key. See [Authentication](/get-started/authentication). ## `BatchCreate` ``` POST /sdk.events.v1.EventsService/BatchCreate ``` Validates and enqueues up to 1 000 events in a single request. Returns the count of accepted events. There is no single-event endpoint - all ingestion goes through this method. All `google.protobuf.Timestamp` fields (here, `occurTime`, and `timestampValue` inside a `PropertyValue`) serialize as **RFC 3339 strings** - e.g. `"2026-06-05T10:00:00Z"`. ## `Event` fields | Proto field | JSON name | Type | Required | Notes | |---|---|---|---|---| | `event_id` | `eventId` | string | Yes | UUID (any version; UUIDv7 recommended for time-sortability) | | `auto_properties` | `autoProperties` | `map` | No | Context set by the SDK or server. Keys must start with `$`. `$bot_score` and `$verified_bot` are server-only - any client-supplied value is stripped and replaced from CDN headers | | `custom_properties` | `customProperties` | `map` | No | Your event properties. Keys must not start with `$` | | `distinct_id` | `distinctId` | string | Unless `cookieless` | Anonymous or identified user ID. Must not start with `cookieless-` - that prefix is server-owned | | `kind` | `kind` | string | Yes | Event name, matching `^[a-zA-Z0-9_.-]+$`. Must not start with `pug.` (reserved prefix) | | `occur_time` | `occurTime` | `google.protobuf.Timestamp` -> RFC 3339 | Yes | When the event occurred | | `session_id` | `sessionId` | string | Unless `cookieless` | UUID grouping events in a visit | | `cookieless` | `cookieless` | bool | No | Ask the server to derive identity instead of sending it. See [Cookieless events](#cookieless-events) | Proto field names (snake_case) and lowerCamelCase are both accepted by the Connect JSON codec. The lowerCamelCase forms shown above are canonical. ## Cookieless events Set `"cookieless": true` when the client has stored no identifier - the Web SDK's default [consent state](/sdks#tracking-consent), and the shape to send from any client that hasn't obtained consent. The server then derives an ephemeral, daily-rotating `distinctId` and stitches a `sessionId` (closed after 30 minutes of inactivity), so the traffic still counts while staying anonymous. ```json { "eventId": "01966b9e-1234-7abc-abcd-0123456789ab", "kind": "page_view", "cookieless": true, "occurTime": "2026-06-05T10:00:00Z", "customProperties": {}, "autoProperties": {} } ``` The identity fields must be **omitted entirely**, not sent empty: a set `sessionId` has to satisfy the UUID rule, so `""` is rejected. A batch may mix cookieless and identified events freely. Derived IDs carry a `cookieless-` prefix, which is why a client may never send one. They are excluded from user-counting metrics by default, so a project running cookieless sees visitor counts without inflated user counts. ## `PropertyValue`: typed property wrapper Every value in `customProperties` and `autoProperties` is a wrapped object that names its type explicitly. This is the Connect JSON encoding of the protobuf `oneof`. | Variant | JSON shape | Notes | |---|---|---| | String | `{ "stringValue": "hello" }` | Max 1 024 Unicode code points. Values exceeding the limit are rejected with `invalid_argument` (not truncated) | | Integer | `{ "intValue": "42" }` | int64 - the value is a **JSON string**, not a number | | Double | `{ "doubleValue": 3.14 }` | Must be finite (NaN and +/-Infinity are rejected) | | Boolean | `{ "boolValue": true }` | | | Timestamp | `{ "timestampValue": "2026-06-05T10:00:00Z" }` | RFC 3339; stored at millisecond precision | Exactly one variant must be set per `PropertyValue` object. Proto snake_case aliases (e.g. `string_value`) are accepted as well. ## Validation summary All rules above enforced together; any violation fails the request with `invalid_argument`: - **Batch size**: at most 1 000 events per `BatchCreate` call. - **`eventId`, `sessionId`**: must be valid UUIDs. - **Identity**: `distinctId` and `sessionId` are required unless `cookieless` is `true`, and must be omitted entirely when it is. - **`cookieless-` prefix**: server-owned; a client-sent `distinctId` starting with it is rejected (as is an `externalId` on [Identify](/api/profiles)). - **`$bot_score`, `$verified_bot`**: server-only; any client-supplied value is dropped and re-set from the CDN-injected `CF-Bot-Score`/`CF-Verified-Bot` headers. (Property-key prefix rules, `stringValue` length, and `doubleValue` finiteness are covered in the [`Event` fields](#event-fields) and [`PropertyValue`](#propertyvalue-typed-property-wrapper) tables above.) ## Request / response shape > **Property values are typed `PropertyValue` wrappers.** Every value in `customProperties` and `autoProperties` names its type explicitly: `intValue` (int64) must be a JSON **string**, doubles are bare numbers, etc. (see the table above). This is **not** how profile traits work: Identify's `traits` are plain JSON, **not** `PropertyValue`-wrapped; see [Profiles](/api/profiles). ### `BatchCreateRequest` ```json { "events": [ { "eventId": "", "distinctId": "anon-abc123", "kind": "page_view", "sessionId": "", "occurTime": "2026-06-05T10:00:00Z", "customProperties": { "plan": { "stringValue": "pro" }, "seats": { "intValue": "5" }, "mrr": { "doubleValue": 299.0 }, "trial": { "boolValue": false } }, "autoProperties": {} } ] } ``` ### `BatchCreateResponse` ```json { "accepted": 1 } ``` `accepted` (`uint32`) is the number of events that passed validation and were enqueued. It equals the number of events in the request when all events are valid. Two more fields appear when the server refuses part of a batch while resolving [cookieless](#cookieless-events) identity - `accepted` plus `dropped` always equals the number sent: ```json { "accepted": 8, "dropped": 2, "droppedByReason": { "salt_unavailable": 2 } } ``` A partial drop is deliberately **not** an error: a batch may legitimately mix cookieless and identified events, so failing the whole request would discard healthy traffic to report a fault affecting part of it. That makes `dropped` the only signal that anything was lost - check it rather than assuming a 200 means everything landed. | Reason | Cause | Retry the same payload? | |---|---|---| | `day_out_of_range` | `occurTime` fell outside the today/yesterday UTC salt window (usually severe client clock skew) | No - it drops again | | `salt_unavailable` | The daily salt could not be read or minted, so identity can't be derived (and is never fabricated). Server-side | Yes | | `salt_corrupt` | The day's stored salt could not be decoded. Nothing overwrites it, so it re-rejects until the key expires | No - needs operator intervention | A fourth reason, `identity_headers_missing` (no `User-Agent`, or no resolvable client address - usually a proxy stripping headers), rejects the **whole** request instead, so it surfaces as an error rather than in `droppedByReason`. ## Errors These are the codes `BatchCreate` returns in practice - a subset of the full [error model](/api#error-model): | Connect code | HTTP status | Cause | |---|---|---| | `unauthenticated` | 401 | Missing or invalid `x-api-key` | | `invalid_argument` | 400 | Validation failed: see the `message` field for which rule | | `resource_exhausted` | 429 | Rate limit exceeded | | `internal` | 500 | Server error: retry with exponential backoff | Error responses follow the Connect envelope: ```json { "code": "invalid_argument", "message": "event kind must not use reserved prefix 'pug.'" } ``` ## Further reading - [HTTP transport](/sdks#errors-and-the-raw-http-api): full curl, Python, and Go examples for `BatchCreate` - [Auto-properties](/reference/auto-properties): the `$`-prefixed property catalog set by the server - [Authentication](/get-started/authentication): public vs. private keys, key rotation # Insights API > Run trends, funnel, retention, segmentation, user-flow, top-K and map queries programmatically with a private key. Source: https://docs.pug.sh/api/insights/ | Markdown: https://docs.pug.sh/api/insights/index.md Run analytics queries programmatically against the same engine that powers dashboard charts, overview KPIs, and saved tiles. The Insights API is a server-side interface - it is not callable from browser clients. **Service:** `shared.insights.v1.InsightsService` **Auth:** Dual - private key (`prv_...`) via `x-api-key` or dashboard JWT via `Authorization: Bearer`. See [Authentication](/get-started/authentication). ## Methods ### `Query` ``` POST /shared.insights.v1.InsightsService/Query ``` Runs an analytics query and returns a typed result. The shape of the result depends on `spec.insight_type` (see [Insight types](#insight-types)). #### Request: `QueryRequest` | Field | Type | Required | Description | |-------|------|----------|-------------| | `spec` | `InsightQuerySpec` | Yes | The query specification (insight type, events, breakdowns, filters). | | `time_range` | `TimeRange` | Yes | Absolute start/end (`from`, `to`) as RFC 3339 timestamps. `from` must be before `to`. | | `granularity` | `Granularity` | Yes | Time bucket size. See [Granularities](#granularities). | #### Response: `QueryResponse` The response carries exactly one of the following fields, selected by `spec.insight_type`: | Field | Type | Populated when | |-------|------|----------------| | `trends` | `TrendsResult` | `INSIGHT_TYPE_TRENDS` | | `segmentation` | `SegmentationResult` | `INSIGHT_TYPE_SEGMENTATION` | | `funnel` | `FunnelResult` | `INSIGHT_TYPE_FUNNEL` | | `retention` | `RetentionResult` | `INSIGHT_TYPE_RETENTION` | | `user_flow` | `UserFlowResult` | `INSIGHT_TYPE_USER_FLOW` | | `top_k` | `TopKResult` | `INSIGHT_TYPE_TOP_K`, `INSIGHT_TYPE_MAP` | ### `SegmentUsers` ``` POST /shared.insights.v1.InsightsService/SegmentUsers ``` Returns a paginated list of distinct user IDs that match a set of event conditions within a time range. Useful for audience export or cross-referencing profiles. | Field | Type | Description | |-------|------|-------------| | `time_range` | `TimeRange` | Required. | | `events` | `EventQuery[]` | Required, at least one. | | `filter_groups` | `FilterGroup[]` | Optional top-level filters. | | `filter_groups_operator` | `LogicalOperator` | How groups are combined (default `AND`). | | `page_size` | `int32` | 0-1000. | | `page_token` | `string` | Cursor from previous response. | Response: `{ distinct_ids: string[], next_page_token: string }`. ### `GetFilterSchema` ``` POST /shared.insights.v1.InsightsService/GetFilterSchema ``` Returns the set of known event kinds and property keys (auto, custom, profile) for the project. Use this to populate filter pickers. The response shape is `common.v1.GetFilterSchemaResponse`. Optionally scoped to a single event kind or filtered to specific `PropertyValueType` values. ### `GetPropertyValues` ``` POST /shared.insights.v1.InsightsService/GetPropertyValues ``` Returns the distinct observed values for a specific property key. Useful for populating `FILTER_OPERATOR_IN` value lists. | Field | Type | Required | Description | |-------|------|----------|-------------| | `property_key` | `string` | Yes | The property to enumerate (e.g. `$country`, `plan`). Include the `$` prefix for auto-properties. | | `source` | `PropertySource` | Yes | Which namespace the key lives in: `PROPERTY_SOURCE_AUTO`, `PROPERTY_SOURCE_CUSTOM`, or `PROPERTY_SOURCE_PROFILE`. | | `event_kind` | `string` | No | Restrict observed values to a single event kind. Omit to scan across all events. | Response: `{ "values": string[] }` - the distinct observed values. ```json { "propertyKey": "$country", "source": "PROPERTY_SOURCE_AUTO" } ``` ## Insight types `InsightQuerySpec.insight_type` selects the computation and the response field. | Enum value | Description | |------------|-------------| | `INSIGHT_TYPE_TRENDS` | Time-series event counts or aggregations. One `TrendSeries` per `(event, breakdown)` combination. | | `INSIGHT_TYPE_SEGMENTATION` | Single scalar: total count or aggregation across the full time range, no time axis. | | `INSIGHT_TYPE_FUNNEL` | Multi-step conversion funnel. Steps are the `events` array in order. Each series carries per-step counts; optionally includes per-step timing statistics when `include_step_timing` is true. | | `INSIGHT_TYPE_RETENTION` | Cohort retention matrix. `events[0]` is the starting event; `events[1]` (optional) is the return event. Returns cohorts bucketed by the query granularity. | | `INSIGHT_TYPE_USER_FLOW` | Sankey / path graph showing transitions between events or property values. Requires a `user_flow` sub-spec; no `events` field. | | `INSIGHT_TYPE_TOP_K` | Ranking: the top K values of a dimension by an aggregate metric, plus a trailing `$others` bucket. Requires a `top_k` sub-spec; no `events` field, no time axis. See [`TopKQuery`](#topkquery). | | `INSIGHT_TYPE_MAP` | Choropleth: one metric per country. The dimension is fixed to `$country`, which is what makes it an insight type rather than a chart style. Requires a `map` sub-spec; no `events` field, no time axis. Results arrive in `top_k`. See [`MapQuery`](#mapquery). | ### Insight type constraints - **`INSIGHT_TYPE_FUNNEL`**: requires at least one event; supports `breakdowns` (max 5); supports `conversion_window` (whole seconds, min 1 s); supports `include_step_timing`. - **`INSIGHT_TYPE_RETENTION`**: requires at least one event; accepts at most 2 (`events[0]` = start, `events[1]` = return). - **`INSIGHT_TYPE_SEGMENTATION`**: does not support `breakdowns`. - **`INSIGHT_TYPE_USER_FLOW`**: requires a `user_flow` sub-spec; `events` must be empty; `breakdowns` are not supported. - **`INSIGHT_TYPE_TOP_K`**: requires a `top_k` sub-spec; `events`, `session`, `breakdowns` and `breakdown_limit` must all be empty. Scope events through `top_k.scope` instead. - **`INSIGHT_TYPE_MAP`**: requires a `map` sub-spec; `events`, `session`, `breakdowns` and `breakdown_limit` must all be empty. Scope events through `map.scope` instead. The country dimension is implied, so there is nothing to break down by and no top-N to bound. - **`INSIGHT_TYPE_TRENDS`** / **`INSIGHT_TYPE_SEGMENTATION`**: support numeric aggregations (`AGGREGATION_TYPE_SUM`, `AVG`, `MIN`, `MAX`); these are not valid for funnel or retention. ## Granularities `QueryRequest.granularity` controls the time-bucket width and imposes a maximum query range. | Enum value | Bucket width | Maximum time range | |------------|-------------|-------------------| | `GRANULARITY_MINUTE` | 1 minute | 6 hours | | `GRANULARITY_HOUR` | 1 hour | 14 days | | `GRANULARITY_DAY` | 1 day | 365 days | | `GRANULARITY_WEEK` | 1 week | ~4 years (1461 days) | | `GRANULARITY_MONTH` | 1 month | ~10 years (3652 days) | ## `InsightQuerySpec` fields | Field | Type | Description | |-------|------|-------------| | `insight_type` | `InsightType` | Required. Selects the computation and response shape. | | `events` | `EventQuery[]` | Event series (for trends, segmentation, funnel, retention). Each entry selects one event kind with optional filters and an aggregation type. | | `breakdowns` | `Breakdown[]` | Group results by up to 5 properties (`{ property: string }`). Not supported for segmentation or user flow. | | `breakdown_limit` | `int32` | Cap on number of breakdown values returned (0-100). Requires at least one breakdown. | | `filter_groups` | `FilterGroup[]` | Top-level property filters. Multiple groups are combined by `filter_groups_operator`. | | `filter_groups_operator` | `LogicalOperator` | How filter groups are combined. Defaults to `LOGICAL_OPERATOR_AND`. | | `conversion_window` | `Duration` | Funnel only. Maximum time from step 1 to last step per user. Whole seconds, min 1 s. Absent = no constraint. | | `include_step_timing` | `bool` | Funnel only. When true, each non-entry step includes `StepTiming` (avg, median, p95, 8-bucket histogram). | | `session` | `SessionQuery` | Trends/segmentation only. Query session-level metrics instead of events. `events` must be empty when set. | | `user_flow` | `UserFlowQuery` | User-flow only. Configures the Sankey graph (node kind, max hops, max nodes, max links). | | `top_k` | `TopKQuery` | Top-K only. Configures the ranking (dimension, metric, limit). See [`TopKQuery`](#topkquery). | | `map` | `MapQuery` | Map only. Configures the per-country metric (scope, metric, metric property). See [`MapQuery`](#mapquery). | | `include_cookieless` | `bool` | Count [cookieless visitors](/api/events#cookieless-events) in user-based results. Defaults to `false` - see below. | ### Cookieless visitors are excluded from user counts Cookieless IDs rotate daily, so one returning human would count as a new user every day. By default they are excluded from: - **`UNIQUE_USERS` and `PER_USER_AVG`**, numerator and denominator alike, wherever those metrics appear: trends, segmentation, [top K](#topkquery), [map](#mapquery). - **Person-based insights**: funnel, retention, user flow, and `DIMENSION_USER` top K. These resolve people rather than events, so they exclude regardless of which metric is selected. Event totals, numeric aggregations and all session metrics always count every visitor, cookieless or not. Set `include_cookieless: true` to fold them into the metrics above as well - useful for a relative trend, misleading as an absolute user count. ### `EventQuery` | Field | Type | Description | |-------|------|-------------| | `event` | `EventFilter` | Event kind + optional per-event property filters. | | `aggregation` | `AggregationType` | Defaults to `AGGREGATION_TYPE_TOTAL`. | | `aggregation_property` | `string` | Required for `SUM`, `AVG`, `MIN`, `MAX`. Property name to aggregate (e.g. `revenue`). | ### Aggregation types | Enum value | Description | |------------|-------------| | `AGGREGATION_TYPE_TOTAL` | Total event occurrences. | | `AGGREGATION_TYPE_UNIQUE_USERS` | Count of distinct users. | | `AGGREGATION_TYPE_PER_USER_AVG` | Average events per user. | | `AGGREGATION_TYPE_SUM` | Sum of a numeric property. Requires `aggregation_property`. | | `AGGREGATION_TYPE_AVG` | Mean of a numeric property. Requires `aggregation_property`. | | `AGGREGATION_TYPE_MIN` | Minimum of a numeric property. Requires `aggregation_property`. | | `AGGREGATION_TYPE_MAX` | Maximum of a numeric property. Requires `aggregation_property`. | ### `TopKQuery` Ranks the top values of one dimension by an aggregate metric. Top K has **no time axis** - `granularity` is still required on the request, but for this insight type it only selects which [time-range cap](#granularities) applies, so send the coarsest granularity whose cap admits your window. | Field | Type | Description | |-------|------|-------------| | `dimension` | `Dimension` | Required. What is being ranked - see below. | | `property` | `string` | The property to rank values of. Required when `dimension` is `DIMENSION_PROPERTY`, and rejected otherwise. Same `$`-prefix encoding as `Breakdown.property`. | | `scope` | `EventFilter` | Optional event scope (kind and/or per-event filters). Empty means all events participate. This replaces `events`, which top K may not set. | | `metric` | `AggregationType` | Metric to rank by. Defaults to `AGGREGATION_TYPE_TOTAL`. | | `metric_property` | `string` | Property to aggregate. Required for `SUM`, `AVG`, `MIN`, `MAX`; ignored otherwise. | | `limit` | `int32` | Rows to return, 0-100. Defaults to 10. | | `omit_others` | `bool` | Drop the trailing synthetic `$others` bucket and return only the top `limit` rows. Defaults to `false`. | | `Dimension` | Ranks | |---|---| | `DIMENSION_PROPERTY` | Values of an event property, auto or custom, named by `property`. | | `DIMENSION_EVENT_KIND` | Event kinds: "top events". | | `DIMENSION_USER` | Canonical users. Rows carry profile enrichment where the key resolves to a profile. `UNIQUE_USERS` and `PER_USER_AVG` are rejected here - each group is a single user, so those metrics are degenerate. | `TopKResult` holds `rows`, ordered metric-descending with the `$others` bucket last. Each `TopKRow` carries `dimension_value`, `value`, `is_others`, and, for resolved `DIMENSION_USER` rows only, a `profile` (`id`, `external_id`, `properties`). Identify the overflow bucket by **`is_others`, never by matching `dimension_value` against `"$others"`** - a real property value or event kind can legitimately be that literal string. The same contract applies to `UserFlowNode`. ### `MapQuery` Measures one metric per country for a choropleth. The dimension is fixed to the `$country` auto-property - there is no `dimension` or `property` field to set, and no `limit`. Like top K it has **no time axis**, so `granularity` only selects which [time-range cap](#granularities) applies. | Field | Type | Description | |-------|------|-------------| | `scope` | `EventFilter` | Optional event scope (kind and/or per-event filters). Empty means all events participate. This replaces `events`, which map may not set. | | `metric` | `AggregationType` | Metric to measure per country. Defaults to `AGGREGATION_TYPE_TOTAL`. | | `metric_property` | `string` | Property to aggregate. Required for `SUM`, `AVG`, `MIN`, `MAX`; rejected for the counting metrics. | Results arrive in `QueryResponse.top_k` as a `TopKResult`, ordered metric-descending, with `dimension_value` set to the country code. There is **no `$others` bucket** - every row is a country. > **Map rows do not sum to your project total** > > Every returned key is an assigned **ISO 3166-1 alpha-2** code. Rows the server cannot confirm as one are dropped - traffic the geo lookup could not resolve, and any value a client wrote to `$country` directly (`"USA"`, `"unknown"`, unassigned codes like `"ZZ"`). Compute percentages against the row set, not against a separate total. > > Ranking happens **before** that filter, against an internal cap of 250 countries, so a project flooded with junk `$country` values can push real countries out of the result. ## Filter model Filters are expressed as `FilterGroup` objects inside `spec.filter_groups`. Each group contains one or more `PropertyFilter` entries combined by the group's own `operator`. Groups are then combined by `spec.filter_groups_operator`. ### `FilterGroup` ```json { "filters": [ { "property": "plan", "operator": "FILTER_OPERATOR_EQUALS", "value": "pro" }, { "property": "$country", "operator": "FILTER_OPERATOR_IN", "values": ["US", "CA"] } ], "operator": "LOGICAL_OPERATOR_AND" } ``` ### Filter operators (`FilterOperator`) | Enum value | Value fields used | Description | |------------|------------------|-------------| | `FILTER_OPERATOR_EQUALS` | `value` | Exact match. | | `FILTER_OPERATOR_NOT_EQUALS` | `value` | Excludes exact match. | | `FILTER_OPERATOR_CONTAINS` | `value` | Substring match. | | `FILTER_OPERATOR_NOT_CONTAINS` | `value` | Excludes substring match. | | `FILTER_OPERATOR_IS_SET` | - | Property exists. | | `FILTER_OPERATOR_IS_NOT_SET` | - | Property is absent. | | `FILTER_OPERATOR_LTE` | `value` (numeric) | Less than or equal. | | `FILTER_OPERATOR_GTE` | `value` (numeric) | Greater than or equal. | | `FILTER_OPERATOR_LT` | `value` (numeric) | Strictly less than. | | `FILTER_OPERATOR_GT` | `value` (numeric) | Strictly greater than. | | `FILTER_OPERATOR_IN` | `values` | Match any of a list. | | `FILTER_OPERATOR_NOT_IN` | `values` | Exclude all of a list. | | `FILTER_OPERATOR_BETWEEN` | `values[0]`, `values[1]` (numeric, ordered) | Inclusive range. | | `FILTER_OPERATOR_NOT_BETWEEN` | `values[0]`, `values[1]` (numeric, ordered) | Outside range. | ### Logical operators (`LogicalOperator`) | Enum value | Description | |------------|-------------| | `LOGICAL_OPERATOR_AND` | All conditions must match (default). | | `LOGICAL_OPERATOR_OR` | At least one condition must match. | Property names follow the pattern `^\\$?[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)*$`. Auto-properties use their flat `$` key (e.g. `$country`, `$browser`); a dotted name addresses a nested key inside a custom property's JSON (e.g. `metadata.tier`). For filterable auto-properties see [Auto-properties](/reference/auto-properties). ## Session metrics When `spec.session` is set, the query measures session-level data instead of individual events. `spec.events` must be empty. | Enum value | Description | Restrictions | |------------|-------------|--------------| | `SESSION_METRIC_SESSIONS` | Count of distinct sessions started in the window. | Trends or segmentation. | | `SESSION_METRIC_AVG_DURATION` | Average session duration in seconds (last event - first event). | Trends or segmentation. | | `SESSION_METRIC_BOUNCE_RATE` | Percentage of sessions with exactly one event (after optional scope filter). | Trends or segmentation. | | `SESSION_METRIC_ENTRY` | Count sessions by their first matching event's breakdown value (entry page). | Trends + exactly one breakdown. | | `SESSION_METRIC_EXIT` | Count sessions by their last matching event's breakdown value (exit page). | Trends + exactly one breakdown. | `SessionQuery.scope` (optional `EventFilter`) restricts which events participate in the metric. An empty scope considers all events in the session. ## Worked example: TRENDS query A daily time series of unique users who fired `page_view`, broken down by `$country`, over the last 30 days. ```http POST /shared.insights.v1.InsightsService/Query Content-Type: application/json Connect-Protocol-Version: 1 x-api-key: prv_... ``` ```json { "spec": { "insight_type": "INSIGHT_TYPE_TRENDS", "events": [ { "event": { "kind": "page_view" }, "aggregation": "AGGREGATION_TYPE_UNIQUE_USERS" } ], "breakdowns": [ { "property": "$country" } ], "breakdown_limit": 10 }, "time_range": { "from": "2026-05-06T00:00:00Z", "to": "2026-06-05T00:00:00Z" }, "granularity": "GRANULARITY_DAY" } ``` Response sketch: ```json { "trends": { "series": [ { "event_kind": "page_view", "breakdown": { "$country": "US" }, "points": [ { "time": "2026-05-06T00:00:00Z", "value": 412 }, { "time": "2026-05-07T00:00:00Z", "value": 389 } ] }, { "event_kind": "page_view", "breakdown": { "$country": "GB" }, "points": [ { "time": "2026-05-06T00:00:00Z", "value": 104 }, { "time": "2026-05-07T00:00:00Z", "value": 97 } ] } ] } } ``` Each `series` entry corresponds to one `(event_kind, breakdown)` pair. `points` are in ascending time order with one entry per granularity bucket. ## Worked example: FUNNEL query ```http POST /shared.insights.v1.InsightsService/Query Content-Type: application/json Connect-Protocol-Version: 1 x-api-key: prv_... ``` ```json { "spec": { "insight_type": "INSIGHT_TYPE_FUNNEL", "events": [ { "event": { "kind": "signup_started" } }, { "event": { "kind": "signup_completed" } }, { "event": { "kind": "purchase" } } ], "conversion_window": "604800s" }, "time_range": { "from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z" }, "granularity": "GRANULARITY_DAY" } ``` Response sketch: ```json { "funnel": { "series": [ { "breakdown": {}, "steps": [ { "event_kind": "signup_started", "total": 1000 }, { "event_kind": "signup_completed", "total": 650 }, { "event_kind": "purchase", "total": 120 } ] } ] } } ``` With `include_step_timing: true`, each non-entry step gains a `timing` object: ```json { "event_kind": "signup_completed", "total": 650, "timing": { "avg": "180s", "median": "95s", "p95": "1200s", "distribution": [ { "label": "0-30s", "upper_bound": "30s", "count": 78 }, { "label": "30s-2m", "upper_bound": "120s", "count": 210 } ] } } ``` The `distribution` array above is **truncated** for brevity - it always contains exactly 8 buckets in the canonical order, and the 8 boundaries are fixed (a full response returns all 8). ## Performance notes Insights queries run against ClickHouse. Query time scales with: - **Time range x granularity**: wider ranges at fine granularities scan more partitions. - **Breakdown cardinality**: avoid very high-cardinality breakdowns (e.g. user ID). Use `breakdown_limit` to cap result size. - **Funnel step count**: funnel supports at most 20 steps. ## Related - [API reference](/api): base URL, auth, error codes - [Auto-properties](/reference/auto-properties): filterable `$`-prefixed properties - [RPC services](/reference/rpc-services): full method index # Profiles API > Identify users, read and list profiles, and run data-subject deletion across the SDK and shared profile services. Source: https://docs.pug.sh/api/profiles/ | Markdown: https://docs.pug.sh/api/profiles/index.md Pug maintains a profile for every user your project tracks - identified or anonymous. Profiles accumulate traits (properties) and are linked to the events they generate. Two RPC services cover the full lifecycle: | Service | Auth | What it does | |---------|------|--------------| | `sdk.profiles.v1.ProfilesSDKService` | `x-api-key` (SDK key) | Write path: create or update a profile from the SDK | | `shared.profiles.v1.ProfilesService` | Dual (`prv_...` or JWT) | Read / manage: query, list, delete | See [Authentication](/get-started/authentication) and the [auth matrix](/api#auth-matrix) for key types and headers. ## `ProfilesSDKService`: write path ``` POST /sdk.profiles.v1.ProfilesSDKService/Identify ``` Creates or updates a profile identified by `external_id`. If the profile already exists, traits are **shallow-merged** - provided keys overwrite stored values for those keys, other keys are left untouched. > **Traits are plain JSON, not `PropertyValue`-wrapped.** Unlike event `customProperties` (which use `{ "intValue": "5" }`-style wrappers), profile `traits` take bare scalars - see [`traits` type](#traits-type-plain-json-not-propertyvalue) below and the [Events API](/api/events). **Auth:** `x-api-key` header with a public key (`pub_...`) or private key (`prv_...`). The project is resolved from the key - no `Authorization` header or `x-project-id` is required. ### `IdentifyRequest` fields | Proto field | JSON name | Type | Required | Notes | |---|---|---|---|---| | `external_id` | `externalId` | string | Yes | Stable user identifier: e.g. your database user ID or email. Must be non-empty, and must not start with `cookieless-` (reserved for the identities ingest derives for [cookieless events](/api/events#cookieless-events)) | | `traits` | `traits` | `google.protobuf.Struct` | No | Plain JSON object. Shallow-merged into existing traits. On key conflict these values win | | `anonymous_id` | `anonymousId` | string | No | SDK-generated anonymous ID. Must be empty or start with `anon-`; max 255 characters. Send on first identify to merge prior anonymous activity | | `device_id` | `deviceId` | string | No | Device identifier (UUID, max 36 characters) to attach to this profile. Mobile SDKs send it on first identify and on account switch - not on every call. Omit for web | ### `traits` type: plain JSON, not `PropertyValue` `traits` is typed as `google.protobuf.Struct`, which serialises as a plain JSON object in Connect JSON - **not** the `PropertyValue`-wrapped format used by event `customProperties`. You do not need the `{ "stringValue": "..." }` wrapper: ```json { "traits": { "email": "user@example.com", "plan": "pro", "seats": 5, "trial": false } } ``` Compare with events (which use `PropertyValue`): `{ "stringValue": "pro" }`. Profiles use plain scalars. ### `anonymous_id` constraint The field must be either empty (omitted) or a string starting with `anon-`. Any other value returns `invalid_argument`. The Web SDK generates IDs in this format automatically. ### Anonymous to identified merge When you include `anonymous_id` on an `Identify` call, the server merges the anonymous profile into the identified one, attaching prior events and properties, then soft-deletes the anonymous record. **Send `anonymous_id` on the first identify only** (when the user signs in). You do not need a separate `alias()` call; there is no such method. ### `IdentifyResponse` The response is empty (`{}`). Writes are asynchronous - the profile record may not be visible via `ProfilesService` for a few seconds after the call returns. ### Wire example ```bash curl -X POST https://api.pugs.dev/sdk.profiles.v1.ProfilesSDKService/Identify \ -H "Content-Type: application/json" \ -H "Connect-Protocol-Version: 1" \ -H "x-api-key: pub_..." \ -d '{ "externalId": "user-123", "anonymousId": "anon-abc456", "traits": { "email": "user@example.com", "plan": "pro", "company": "Acme Inc" } }' ``` For a full curl and language recipe see [HTTP transport - Identifying users](/sdks#identify-and-sessions). ## `ProfilesService`: read and manage **Auth:** Dual - either a private key (`prv_...`) via `x-api-key`, or a dashboard JWT via `Authorization: Bearer`. Public keys are rejected on this path. ```http x-api-key: prv_... # -- or -- Authorization: Bearer ``` ### Methods | Method | Endpoint | |--------|----------| | `Get` | `POST /shared.profiles.v1.ProfilesService/Get` | | `GetByExternalId` | `POST /shared.profiles.v1.ProfilesService/GetByExternalId` | | `List` | `POST /shared.profiles.v1.ProfilesService/List` | | `Delete` | `POST /shared.profiles.v1.ProfilesService/Delete` | ### `Get` Fetch a profile by its internal Pug ID. **Request** | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | Yes | Internal profile ID (as returned by `List` or `GetByExternalId`) | ```json { "id": "01966e2a-e4b2-7000-9000-000000000001" } ``` **Response**: returns a [`Profile`](#profile-message) object. ```json { "profile": { "id": "01966e2a-e4b2-7000-9000-000000000001", "externalId": "user-123", "properties": { "email": "user@example.com", "plan": "pro" }, "projectId": "proj_abc", "createTime": "2026-01-15T10:00:00Z", "updateTime": "2026-06-01T14:30:00Z", "activity": { "firstSeen": "2026-01-15T10:00:00Z", "lastSeen": "2026-06-01T14:30:00Z", "totalEvents": 342, "pageviews": 198, "sessions": 27, "browser": "Chrome", "os": "macOS", "country": "US" } } } ``` ### `GetByExternalId` Fetch a profile by the `external_id` you passed to `Identify`. **Request** | Field | Type | Required | Notes | |---|---|---|---| | `external_id` | string | Yes | The stable user identifier set on `Identify` | ```json { "externalId": "user-123" } ``` **Response**: same shape as `Get`. ### `List` Search and page through profiles. `List` is **server-streaming** - the server may return results in multiple response frames over a single HTTP/2 stream (or chunked HTTP/1.1). Each frame carries a batch of profiles and a `next_page_token`. **Request** | Field | JSON name | Type | Notes | |---|---|---|---| | `page_token` | `pageToken` | string | Opaque cursor from a previous response's `nextPageToken`. Empty (or omitted) for the first page | | `filter_groups` | `filterGroups` | `FilterGroup[]` | Zero or more filter groups. Each group has `filters` (array of `PropertyFilter`) and an `operator` (`LOGICAL_OPERATOR_AND` or `LOGICAL_OPERATOR_OR`) to combine within the group | | `filter_groups_operator` | `filterGroupsOperator` | `LogicalOperator` | How to combine multiple `filterGroups` - `LOGICAL_OPERATOR_AND` or `LOGICAL_OPERATOR_OR`. Defaults to `LOGICAL_OPERATOR_AND` when unspecified | Each `PropertyFilter` has three fields: `property` (the trait key, e.g. `"plan"`), `operator` (full enum form - `FILTER_OPERATOR_EQUALS`, `FILTER_OPERATOR_NOT_EQUALS`, `FILTER_OPERATOR_CONTAINS`, `FILTER_OPERATOR_NOT_CONTAINS`, `FILTER_OPERATOR_IS_SET`, `FILTER_OPERATOR_IS_NOT_SET`, `FILTER_OPERATOR_LTE`, `FILTER_OPERATOR_GTE`, `FILTER_OPERATOR_LT`, `FILTER_OPERATOR_GT`, `FILTER_OPERATOR_IN`, `FILTER_OPERATOR_NOT_IN`, `FILTER_OPERATOR_BETWEEN`, `FILTER_OPERATOR_NOT_BETWEEN`; see [Filter operators](/api/insights#filter-operators-filteroperator) in Insights), and `value`/`values` (string or string array for multi-value operators). **Example - filter by plan** ```json { "filterGroups": [ { "filters": [ { "property": "plan", "operator": "FILTER_OPERATOR_EQUALS", "value": "pro" } ], "operator": "LOGICAL_OPERATOR_AND" } ] } ``` **Response** (streamed) ```json { "profiles": [ { "id": "...", "externalId": "user-123", "properties": { "plan": "pro" }, "...": "..." } ], "nextPageToken": "eyJvZmZzZXQiOjUwfQ" } ``` Pass `nextPageToken` as `pageToken` in your next request. An empty `nextPageToken` means no more pages. ### `Delete` Soft-deletes a profile by its internal ID. The profile is hidden from `List` and `GetByExternalId` but event data in ClickHouse is retained. **Request** | Field | Type | Required | Notes | |---|---|---|---| | `id` | string | Yes | Internal profile ID | ```json { "id": "01966e2a-e4b2-7000-9000-000000000001" } ``` **Response**: empty (`{}`). ## `Profile` message Returned by `Get`, `GetByExternalId`, and each frame of `List`. | Proto field | JSON name | Type | Notes | |---|---|---|---| | `id` | `id` | string | Internal Pug profile ID | | `external_id` | `externalId` | string | Stable user identifier set via `Identify` | | `properties` | `properties` | `google.protobuf.Struct` | Trait map: plain JSON object (same encoding as `IdentifyRequest.traits`) | | `project_id` | `projectId` | string | Project this profile belongs to | | `create_time` | `createTime` | RFC 3339 timestamp | When the profile was first created | | `update_time` | `updateTime` | RFC 3339 timestamp | When traits were last updated | | `activity` | `activity` | `ProfileActivitySummary` | Aggregated activity data (see below) | ### `ProfileActivitySummary` fields | Proto field | JSON name | Type | Notes | |---|---|---|---| | `first_seen` | `firstSeen` | RFC 3339 timestamp | Earliest event timestamp for this profile | | `last_seen` | `lastSeen` | RFC 3339 timestamp | Most recent event timestamp | | `total_events` | `totalEvents` | int64 | All events attributed to this profile | | `pageviews` | `pageviews` | int64 | Page-view event count | | `sessions` | `sessions` | int64 | Session count | | `browser` | `browser` | string | Most recently seen browser name | | `browser_version` | `browserVersion` | string | Browser version | | `os` | `os` | string | Operating system name | | `os_version` | `osVersion` | string | OS version | | `device` | `device` | string | Device type or model | | `country` | `country` | string | ISO 3166-1 alpha-2 country code | | `region` | `region` | string | Region / state | | `city` | `city` | string | City name | Activity fields are derived from ClickHouse event data. They reflect writes with a short lag - typically a few seconds after `Identify` returns. ## Errors | Code | HTTP | Cause | |------|------|-------| | `unauthenticated` | 401 | Missing, malformed, or expired credential | | `permission_denied` | 403 | Public key used on a `shared.*` endpoint | | `invalid_argument` | 400 | `external_id` empty or starting with `cookieless-`; `anonymous_id` does not match `^$\|^anon-` or exceeds 255 characters; `device_id` exceeds 36 characters | | `not_found` | 404 | No profile with the given `id` or `external_id` | | `internal` | 500 | Server error: retry with exponential backoff | ## Further reading - [HTTP transport](/sdks#identify-and-sessions): curl and language recipes for `Identify` - [Authentication](/get-started/authentication): public vs. private keys, key rotation - [Auto-properties](/reference/auto-properties): `$`-prefixed properties set by the server on events - [Events API](/api/events): `PropertyValue`-wrapped properties used by `BatchCreate` # Quickstart > Send your first event to Pug in under five minutes - install the Web SDK, track a page view, and confirm it in the dashboard. Source: https://docs.pug.sh/get-started/ | Markdown: https://docs.pug.sh/get-started/index.md Send your first event to Pug in under five minutes. By the end you'll have the Web SDK installed, a `page_view` flowing into your project, and confirmation in the dashboard. ## Prerequisites - A Pug account: [sign up at app.pug.sh](https://app.pug.sh) (or your self-hosted dashboard) - Node.js 18+ and a bundler-based web project (Vite, Next.js, etc.) Building a **mobile app**? This quickstart is web-only - start with the [Flutter setup](/sdks?platform=flutter) instead. Building a **backend**? Use the server-side [Node SDK](/sdks?platform=node), or POST events directly from any language - see [Integrate over HTTP](/sdks?platform=http). Working with a **coding agent**? [Install with AI](/get-started/ai-setup) has a ready-to-paste prompt for each platform that points the agent at these docs. ## 1. Create a project and grab your keys 1. Sign in to the [dashboard](https://app.pug.sh). 2. Create an **organization**, then a **project** inside it. 3. Collect the two values this quickstart needs: - **Project ID**: in the dashboard URL: `/p/`. - **Public key** (`pub_...`): under the **API Keys** section of your project **Overview**; safe to ship in client code. The Web SDK authenticates with your **public key**. (The same screen also shows a server-side **private key** - you don't need it here; see [Authentication](/get-started/authentication).) ## 2. Install the Web SDK ```bash npm install @pug-sh/browser ``` See [Installation](/sdks#install) for pnpm, yarn, bun, and framework-specific setup. No bundler? Drop in the [CDN loader snippet](/sdks#loader-snippet) instead. ## 3. Initialize and track Call `init` once at startup, then `track` anywhere. `init` registers your project ID and public key and begins auto-tracking (page views, clicks). The first argument to `track` is the **event name** - any string you choose. > **This install is cookieless until you ask for consent** > > With no `trackingConsent` set, the SDK captures events but writes **nothing** to > the device (no anonymous ID, no session, no cookie) and the server derives a > daily-rotating anonymous ID instead. Everything in this quickstart works that > way; the one thing that doesn't is `identify()`, which needs a stored identifier > to merge. See [Tracking consent](/sdks#tracking-consent) for the three states and > how to gate them behind a banner. ### Vite / React ```ts // src/main.tsx import { init, track } from '@pug-sh/browser' init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY' }) track('signup', { plan: 'pro' }) ``` ### Next.js App Router `init` must run in the browser, so wrap it in a client component: ```tsx // components/PugProvider.tsx 'use client' import { init } from '@pug-sh/browser' import { useEffect } from 'react' export function PugProvider({ children }: { children: React.ReactNode }) { useEffect(() => { init(process.env.NEXT_PUBLIC_PUG_PROJECT_ID!, { apiKey: process.env.NEXT_PUBLIC_PUG_PUBLIC_KEY! }) }, []) return <>{children} } ``` Add `PugProvider` to your root layout and store the values in `.env.local`: ``` NEXT_PUBLIC_PUG_PROJECT_ID=your-project-id NEXT_PUBLIC_PUG_PUBLIC_KEY=pub_your_public_key ``` > Only ever expose the **public** key with `NEXT_PUBLIC_*`. The private key stays server-side. ## 4. Verify in the dashboard 1. Open your project in the dashboard. 2. Go to **Live** - the active visitor count should tick up within a few seconds. 3. Go to **Events** - `page_view` (auto-tracked on init) and your `signup` event should appear. Events are batched client-side and flushed every few seconds, so allow up to ~10 seconds for the first one. Force an immediate send with `track('signup', {}, { immediate: true })`. ## 5. Identify a user (optional) After sign-in, link anonymous activity to a known user. This step needs **granted** consent - there is no anonymous ID to merge while the default cookieless state is in force, so call `optInTracking()` once the user has agreed: ```ts import { identify, optInTracking } from '@pug-sh/browser' optInTracking() await identify('user-123', { email: 'user@example.com', plan: 'pro' }) ``` `identify()` returns a promise and **never throws** (invalid input, a missing identifier and transport failures are logged and the promise resolves), so you can `await` it without a try/catch. On sign-out, call `reset()` to start a fresh anonymous session. See [Identity & sessions](/sdks#identify-and-sessions). ## You're done when... - [ ] `page_view` and `signup` appear in **Live** and **Events** - [ ] Auto-tracked clicks/scrolls appear (defaults are on) - [ ] `identify()` merges pre-login events into a profile (optional - needs granted consent) ## Common issues | Symptom | Likely cause | Fix | |---------|--------------|-----| | No events in dashboard | Wrong project ID or public key | Re-check the **API Keys** section of your project **Overview** | | Events in console but not dashboard | `dryRun: true` in init options | Remove `dryRun` or set `false` | | CORS errors in the browser | Wrong `endpoint` | Use `https://api.pugs.dev` or your self-hosted URL | | Nothing happens, no errors | `init()` ran outside the browser (SSR) | Call `init()` in a client component / `useEffect` | | Events delayed | Client-side batching | Normal (see step 4) - or use `{ immediate: true }` | | `identify()` does nothing, warns once | Consent is `cookieless` (the default) - no stored ID to merge | `optInTracking()` after the user agrees; see [Tracking consent](/sdks#tracking-consent) | | Event counts look right, unique-user counts look low | Cookieless IDs rotate daily, so unique-user metrics, funnels and retention exclude them by default | Expected before consent; see [`include_cookieless`](/api/insights#cookieless-visitors-are-excluded-from-user-counts) | ## Next steps **Next: [Core concepts](/get-started/concepts)** - orgs, projects, keys, and the event pipeline. Then: - [Initialization](/sdks#initialize): batching, sessions, auto-track options - [Tracking events](/sdks#track-events): custom events and well-known schemas - [Authentication](/get-started/authentication): public vs private keys # Install with AI > Hand your coding agent a prompt that points it at these docs, and let it wire the SDK into your app. Source: https://docs.pug.sh/get-started/ai-setup/ | Markdown: https://docs.pug.sh/get-started/ai-setup/index.md ## The prompt ### Web ```text Add Pug product analytics to this app. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - SDK reference - https://docs.pug.sh/get-started/index.md - quickstart - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the product actually does: its routes or screens, its main models, and the handful of things a user comes here to complete. The events worth tracking are the moments the product exists to produce - an account created, an order placed, a document shared - not every button on the page. If you cannot state the core action in one sentence, keep reading. Then: 1. Install @pug-sh/browser. 2. Call init() exactly once, where the app boots, in the browser only - never during SSR. In the Next.js App Router that means a "use client" provider mounted from the root layout. 3. Read the project ID and public key from environment variables. The public key starts with pub_ and is safe to ship. A private key starts with prv_ and must never reach client code. 4. Leave auto-tracking on and do not hand-roll page views - init() already captures them. 5. Add track() calls for the three or four actions you identified - the ones someone would look at in a weekly review. Property values must be plain JSON scalars: strings, numbers, booleans, dates. 6. If this app has authentication, call identify() after sign-in and reset() on sign-out. Note that the SDK is cookieless until optInTracking() is called, so identify() is a no-op without granted consent - tell me whether I need a consent banner rather than opting users in silently. Name events from the catalog before you invent one. It holds 127 well-known events across 19 families - commerce, authentication, billing, media, forms, support, workspace and more - and a well-known name is type-checked as you write it, validated at ingestion, and fills in dashboards that a custom name leaves empty. Search it for every action you are about to track, and take property names and types from that page rather than from whatever reads idiomatically. Fall back to a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this product does, then list the files you changed, the event names you chose, and the environment variables I need to set. ``` ### Flutter ```text Add Pug product analytics to this Flutter app. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - SDK reference (switch to the Flutter panes) - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the app actually does: its screens and navigation graph, its main models, and the handful of things a user opens the app to complete. The events worth tracking are the moments the app exists to produce - an account created, an order placed, a workout finished - not every tap. If you cannot state the core action in one sentence, keep reading. Then: 1. Add pug_flutter to pubspec.yaml. 2. Await Pug.init() in main(), after WidgetsFlutterBinding.ensureInitialized() and before runApp(). 3. Keep the project ID and public key (pub_...) out of source - pass them with --dart-define and read them with String.fromEnvironment. 4. Register PugRouteObserver on the app's navigatorObservers, otherwise screen views never fire. 5. Add tracking for the three or four actions you identified - the ones someone would look at in a weekly review. 6. If this app has authentication, call Pug.identify() after sign-in and Pug.reset() on sign-out. Name events from the catalog before you invent one. Flutter gets a generated named-argument method for all 127 well-known events - Pug.track.purchase(productId: ..., amount: ...) - so the compiler checks the properties for you, and the dashboards those events drive stay empty for a custom name. Reach for Pug.track. first, search the catalog for every action you are about to track, and pass schema-external properties through extras. Use Pug.track(kind, props: {...}) with a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not. Flutter consent has two states and starts granted, which is not the same as the Web SDK - check the consent section before you add a banner. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this app does, then list the files you changed, the event names you chose, and the dart-defines I need to pass. ``` ### Node ```text Add Pug product analytics to this Node backend. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - SDK reference (switch to the Node panes) - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the service actually does: its routes or handlers, its main models, and the state changes that matter to the business. On a server the events worth tracking are the ones a browser cannot observe - a payment settling, a webhook arriving, a job finishing, a subscription lapsing - not request logging. If you cannot state what this service is for in one sentence, keep reading. Then: 1. Install @pug-sh/node. It is ESM and needs Node 18+. 2. Construct one Pug instance at boot and reuse it across requests - do not build one per request. Its apiKey is the private key (prv_...), read from an environment variable, and it must never be imported into client-side code. 3. Remember the argument order: pug.track(distinctId, kind, props?, options?). The distinctId comes first because a server has no ambient user - use our own user ID, or the anon-... ID forwarded from the client. 4. Add track calls for the three or four state changes you identified. 5. Call identify() when a user record is created or changes materially, not on every request. 6. Await pug.close() in the graceful-shutdown path so buffered events flush, and wire an onError handler so dropped events are logged rather than lost. Name events from the catalog before you invent one. The server SDK type-checks 114 of the 127 well-known events, and the ones a backend is placed to see are exactly the ones whose schemas are worth having - purchase, subscription_started, invoice_paid, payment_failed, order_refunded. Search the catalog for every action you are about to track, take property names and types from that page, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not. There is no reset() or destroy() on the server SDK, and no immediate option on track - use flush(). Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this service does, then list the files you changed, the event names you chose, and the environment variables I need to set. ``` ### HTTP API ```text Add Pug product analytics to this service by calling the HTTP API directly - there is no SDK for this runtime. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - the HTTP panes, including worked request examples - https://docs.pug.sh/api/events/index.md - the full event schema - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the service actually does: its routes or handlers, its main models, and the state changes that matter to the business. The events worth tracking are the moments the service exists to produce, not request logging. If you cannot state what this service is for in one sentence, keep reading. Then: 1. Write one small client that POSTs to /sdk.events.v1.EventsService/BatchCreate on the base URL, with the headers Content-Type: application/json, Connect-Protocol-Version: 1, and x-api-key set to the public key (pub_...) from an environment variable. 2. Wrap every custom property value in its typed form - stringValue, intValue (a JSON string), doubleValue, boolValue, timestampValue. This is the part that is most often wrong. Property keys must not start with a dollar sign, and the event kind must not start with pug. 3. Send events in batches of at most 1000, and read "dropped" and "droppedByReason" on the response - a 200 does not mean every event landed. 4. Call it for the three or four actions you identified. Name events from the catalog before you invent one. All 127 well-known events are available over HTTP, and since nothing here is compile-checked they are the only validation you get: a well-known kind has its properties checked at ingestion and fills in the dashboards built on it, while a custom kind is accepted unvalidated and silently. Search the catalog for every action you are about to track, take property names and types from that page, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not. Do not invent fields that are not in the events schema above. When you are done, tell me in one sentence what you concluded this service does, then show me the client, the event names you chose, and the environment variables I need to set. ``` ## What you'll need Two values the agent can't find on its own - without them it invents a placeholder and leaves it there: 1. **Project ID** - in the dashboard URL, `/p/`. 2. **Public key** (`pub_...`) for a browser or mobile app, or a **private key** (`prv_...`) for a Node backend. Both live under **Settings > API Keys**; see [Authentication](/get-started/authentication). Every prompt asks for environment variables rather than literals. Only the public key may ship to a client - it is write-scoped, so the worst a leaked one buys is junk events. A private key also reads your analytics. ## Check the agent's work Agents are good at the wiring and careless about the parts that only fail in production. Before you merge, confirm: - [ ] `page_view` and your own events appear under **Live** and **Events** in the dashboard. Allow ~10 seconds - events are batched. - [ ] No key is hardcoded, and no `prv_` key appears anywhere a browser can reach. Grep the diff for `pub_` and `prv_`. - [ ] `init` runs **once**, client-side. A second call no-ops with a console warning; an SSR call warns and does nothing. - [ ] The agent's one-line summary of what your product does is actually right. Each prompt asks for it, and it's the cheapest thing to check: if the agent misread the product, every event name below it is wrong too. - [ ] Every event that could have come from the [well-known catalog](/reference/well-known-events) did. This is the check worth making by hand: the prompt asks the agent to name what it rejected, so read that list. A custom name where a catalog event exists costs you the typed schema, leaves the dashboards built on that event empty, and renaming it later splits the metric in two. - [ ] Nobody was silently opted in. On the Web SDK, `optInTracking()` belongs behind a real consent decision - see [Tracking consent](/sdks#tracking-consent). ## Give the agent more than a prompt The prompt is the shortest path. Three more surfaces are worth knowing about: - **Every page as markdown** - Append `/index.md` to any docs URL, or use **Copy page** at the top of a page. [`/llms.txt`](https://docs.pug.sh/llms.txt) indexes the site; [`/llms-full.txt`](https://docs.pug.sh/llms-full.txt) is the whole corpus in one document. - **Read your data from the agent** - The [MCP server](/mcp) gives an agent twelve read-only tools over your own project - run an insight, browse the event stream, look up one user. That's for asking questions after the SDK is in, not for installing it. > **Keep the docs URLs in the prompt** > > It's tempting to trim the "read these first" block. Don't - it is what stops the > agent reaching for an API from a different analytics SDK, which is the failure > mode these prompts exist to prevent. If your agent can't fetch URLs, paste > [`/llms-full.txt`](https://docs.pug.sh/llms-full.txt) into the conversation > instead. # Authentication > Public keys, private keys and dashboard JWTs - which credential reaches which service, and what each error code means. Source: https://docs.pug.sh/get-started/authentication/ | Markdown: https://docs.pug.sh/get-started/authentication/index.md Pug has three auth boundaries. Each maps to a set of RPC services - using the wrong credential returns `Unauthenticated` or `PermissionDenied`. ## The three credentials | Credential | Format | Used by | Header | Reaches | |------------|--------|---------|--------|---------| | **Public key** | `pub_...` | Browser / app SDK | `x-api-key` | `sdk.events.v1.EventsService`, `sdk.profiles.v1.ProfilesSDKService` | | **Private key** | `prv_...` | Your server | `x-api-key` | `shared.*`: insights, profiles, activity | | **JWT** | session token | Dashboard | `Authorization: Bearer` | `dashboard.*` (and `shared.*`) | The project is resolved **from the API key**, so SDK and server requests don't send a project ID. Dashboard JWT requests optionally include an `x-project-id` header to scope to a project. ## Where keys live {/* Dashboard labels mirror the dashboard app at ../app - re-verify there if the UI is renamed. The "API Keys" screen is src/pages/routegen/settings/api-keys/index.page.tsx, listed in settings-layout.tsx; the project Overview's setup view (src/pages/routegen/overview/setup-mode.tsx) surfaces the public key only; the "Live"/"Events" nav labels are in src/components/layout/sidebar.tsx; the /p/:projectId route is in src/pages/routes.ts. */} Keys live under **Settings > API Keys**. A project holds any number of them, so you can issue one per app or service and revoke it on its own. Your project **Overview** also shows the public key next to the SDK setup snippets. - **Public key** is displayed in plain text: it's safe in client code. It can write events and identify users, but cannot read analytics. - **Private key** is shown **once, when you create it**: copy it then. Pug stores only a digest of the key, so it cannot be revealed afterwards; the list shows a mask (`prv_...3f9c`) to tell your private keys apart. Treat it like a password. It can read insights, profiles, and activity. There is no rotate button. To replace a key, create its replacement, update your SDK `apiKey` / server env var, deploy, then revoke the old one. ## Public key: client SDKs The Web and Flutter SDKs take the public key as `apiKey` and send it as `x-api-key` on every request: ```ts import { init } from '@pug-sh/browser' init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY' }) ``` The public key is **write-scoped**: `BatchCreate` (events) and `Identify` (profiles). It cannot reach the `shared.*` read APIs, so it's safe to ship in a browser bundle or mobile binary. ## Private key: your servers Use the private key from trusted server-side code to read analytics. The **[Node SDK](/sdks?platform=node)** wraps these reads - construct a client with your private key and call `pug.insights`, `pug.profiles`, or `pug.activity`: ```ts import { Pug } from '@pug-sh/node' const pug = new Pug({ apiKey: process.env.PUG_PRIVATE_KEY! }) const result = await pug.insights.query({ /* spec, timeRange, granularity */ }) ``` From any other language, send the private key as `x-api-key` against the `shared.*` services (which accept a private key **or** a dashboard JWT): ```bash curl -X POST https://api.pugs.dev/shared.insights.v1.InsightsService/Query \ -H "x-api-key: prv_YOUR_PRIVATE_KEY" \ -H "Connect-Protocol-Version: 1" \ -H "Content-Type: application/json" \ -d '{ /* InsightQuerySpec */ }' ``` Replace the body with a real `InsightQuerySpec` - see [Insights API](/api/insights) for the field-by-field shape and a complete example. The private key is also the credential for Pug's [MCP server](/mcp), which hands the same read APIs to an AI agent as tools. Never ship a private key to a browser, mobile app, or public repo. ## JWT: the dashboard The dashboard obtains a short-lived JWT when a user signs in, and sends it as `Authorization: Bearer ` (plus `x-project-id` to scope to a project). JWTs reach the `dashboard.*` management services and the `shared.*` read services. They're refreshed automatically by the dashboard client - don't hard-code them in scripts; use a private key for automation instead. ## Connect RPC All APIs speak [Connect RPC](https://connectrpc.com/) over HTTP/2 and accept the Connect, gRPC, and gRPC-Web protocols. The request path is `/./`, e.g. `/sdk.events.v1.EventsService/BatchCreate`. For JSON requests, include `Connect-Protocol-Version: 1`. To send events without an SDK, see [Integrate over HTTP](/sdks). To generate a typed client from the protobuf definitions, see the [API overview](/api). ## Error responses Connect returns structured errors: | Code | Meaning | Typical cause | |------|---------|---------------| | `Unauthenticated` | Missing or invalid credential | Wrong key, missing `x-api-key` | | `PermissionDenied` | Valid credential, wrong scope | Public key calling a `shared.*` read API | | `InvalidArgument` | Payload failed validation | A rejected field: see below | | `NotFound` | Resource doesn't exist | Wrong project or profile ID | Common `InvalidArgument` causes: - A required field is missing. - A custom property key is prefixed with `$` (reserved for auto-properties). - `kind` (the event name) starts with `pug.` (a reserved prefix). Responses include field-level details from server-side validation - fix the named field and retry. ## Advanced: proxying > If you need server-side logic before events reach Pug (enrichment, filtering, auth of your own), run your own endpoint that forwards requests to Pug with the appropriate key. This keeps the credential on your backend instead of in the client. ## Further reading - [Core concepts](/get-started/concepts): the auth modes in context - [API overview](/api): service index and conventions - [MCP server](/mcp): connecting an agent with a private key - [RPC services](/reference/rpc-services): every service and its auth mode - [Self-hosting configuration](/self-hosting/configuration): `PUG_JWT_SECRET_KEY` and CORS # Core concepts > Orgs, projects, events, profiles, sessions and insights - the vocabulary every other page builds on. Source: https://docs.pug.sh/get-started/concepts/ | Markdown: https://docs.pug.sh/get-started/concepts/index.md Shared vocabulary used across the SDKs, the API, and the dashboard. Read this once - every other page builds on these ideas. ## Organization and project Pug is multi-tenant. Your data always lives inside a **project**; projects belong to an **organization**. You only deal with these for billing and access - the things you actually instrument are **events** and **profiles**. - `Organization` - billing & member boundary - `Members (roles)` - `Project` - data boundary - `API keys (public + private)` - `Events` - `Profiles` | Concept | What it is | Scope | |---------|-----------|-------| | **Organization** | Team workspace with members and billing | All projects in the org | | **Project** | Isolated analytics environment | Events, profiles, insights | | **Public key** (`pub_...`) | Client credential for SDKs | Single project | | **Private key** (`prv_...`) | Secret credential for your servers | Single project | The dashboard URL is prefixed with the active project: `/p//...`. ## Event pipeline When your app calls `track()`, the event travels through an async pipeline before it appears in analytics. {"track('purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' })"} **Timing:** ingestion is near-real-time. Events typically appear in **Live** within seconds and in **Insights** queries within the same minute. Profile writes are fully async, processed by profile workers. ## Events An **event** is a named occurrence with properties and a timestamp. ```ts track('button_clicked', { label: 'Sign up', page: '/pricing' }) ``` - **Event name** (`kind`): any string; [well-known events](/reference/well-known-events) have typed schemas. Names starting with `pug.` are reserved. - **Custom properties**: your key/value pairs (string, number, boolean, timestamp). - **Auto-properties**: fields prefixed with `$` that the SDK and server attach automatically (e.g. `$url`, `$browser`, `$country`). `$`-prefixed keys are reserved; see [Auto-properties](/reference/auto-properties). - **Timestamp**: defaults to send time; override per event for offline replay. Events are immutable once stored. Corrections mean a new event or a profile-trait update. ## Profiles A **profile** represents a user - anonymous or identified. ### Anonymous to identified 1. A visitor arrives and consents. The SDK creates an **anonymous ID** (`anon-...`) tied to the device. 2. Events accumulate against that anonymous ID. 3. The user signs in. You call `identify('user-123', { email: '...' })`. 4. On that **first** identify, the SDK sends the anonymous ID alongside your external ID so the backend can **merge** the prior anonymous activity into the identified profile. Step 1 is what consent buys. Under the Web SDK's default [cookieless](/sdks#tracking-consent) state there is no device-side anonymous ID at all - the server derives a daily-rotating one instead, events still count as traffic, and step 4 has nothing to merge. Granting consent later starts a fresh anonymous ID; it does not reach back. To join the two halves of that funnel you need a value you know on both sides, such as an email captured at signup and passed to `identify()`. ```ts // Before sign-in - anonymous track('page_view') track('add_to_cart', { productId: 'sku_123' }) // After sign-in - merges anonymous history into the identified profile await identify('user-123', { email: 'user@example.com', plan: 'pro' }) track('purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' }) ``` Three ID terms recur across the SDKs and API: | ID | What it is | When used | |----|-----------|-----------| | **Anonymous ID** (`anon-...`) | Device ID the SDK generates | Before sign-in, once consent is granted | | **External ID** | Your stable user ID, passed to `identify()` | After sign-in | | **Distinct ID** | The umbrella value stored on every event | Anonymous ID until identified, then external ID | | **Cookieless ID** (`cookieless-...`) | Server-derived, rotates daily, never stored on the device | Before consent; excluded from user counts | ### Storage and timing - **Writes** (identify, trait updates, merges) are processed asynchronously by profile workers via NATS. - Expect a few seconds between an `identify()` call and the profile appearing in dashboard search. - Use a **stable** external ID (your database user ID), not something that changes like an email. ## Sessions A **session** groups events within a single visit. SDKs manage session IDs automatically: - A new session starts on the first event after the **idle timeout** (default 30 minutes of inactivity). - The session extends on each event inside the window, up to a **max duration** (default 24 hours). - The Web SDK syncs the session ID across browser tabs via `localStorage`. - Before consent, the Web SDK stores no session and sends none - the server stitches one from the cookieless ID instead, on the same 30-minute inactivity rule. Force a new session without clearing identity with `rotate()`. Clear identity and start fresh with `reset()`. ## Insights An **insight** is a query specification - the definition of an analytics question, not a saved chart: | Field | Example | |-------|---------| | Events | `page_view`, `purchase` | | Time range | Last 7 days | | Granularity | Day | | Aggregation | Unique users | | Breakdown | `$country` | | Filters | `plan = 'pro'` | The same insight model powers the dashboard's charts and the [`shared.insights.v1.InsightsService`](/api/insights) API, which you can query from your own backend with a **private key**. Insight types include trends, funnels, retention, segmentation, user flow, and session metrics. ## Authentication modes Pug has three auth boundaries: the **public key** (`pub_...`) authenticates client SDKs, the **private key** (`prv_...`) authenticates your server, and a **JWT** authenticates the dashboard. See [Authentication](/get-started/authentication) for the full matrix of headers, scopes, and examples. ## Further reading - [Authentication](/get-started/authentication): keys, headers, Connect RPC - [Well-known events](/reference/well-known-events): typed event schemas - [Glossary](/reference/glossary): quick term lookup # MCP server > Connect Claude, Cursor or any MCP client to your Pug project - twelve read-only analytics tools behind a private API key. Source: https://docs.pug.sh/mcp/ | Markdown: https://docs.pug.sh/mcp/index.md Pug serves its read-only analytics API as a **Model Context Protocol** server at `/mcp`, so an agent can answer questions about your product's data (run an insight, browse the raw event stream, look up one user) without you writing the query. Twelve tools, one project, no writes. The endpoint is a thin adapter, not a second API: every tool call is replayed through the same Connect handlers an HTTP request hits, so validation, authentication and authorization behave identically. Anything the [Insights](/api/insights), [Profiles](/api/profiles) or Activity services refuse over HTTP, they refuse here. ## Endpoint | Environment | URL | |-------------|-----| | Pug Cloud | `https://api.pugs.dev/mcp` | | Self-hosted / local | `http://localhost:3000/mcp` | Transport is **streamable HTTP** in stateless mode - no stdio bridge, so your client needs remote-server support. Both `/mcp` and `/mcp/` are served, so a trailing slash from an ingress or a pasted URL is fine. Every reply comes back on the POST as JSON. The spec's optional standalone SSE stream (`GET /mcp`) is refused with `405` - stateless means there is no session for it to attach to. Clients that treat that stream as optional, which is all of the ones above, are unaffected. ## Authentication `/mcp` accepts a **private key only**. Send it either way: ```http Authorization: Bearer prv_YOUR_PRIVATE_KEY ``` ```http x-api-key: prv_YOUR_PRIVATE_KEY ``` Most MCP clients can only attach an `Authorization` header, so Pug normalises the `Bearer prv_...` form into the `x-api-key` the rest of the API expects. Reach for whichever your client makes easy. The key resolves the project, so there is no project ID in the config and the agent sees exactly one project's data. Public keys (`pub_...`) and dashboard JWTs are both rejected with `401` - a public key is extractable from client code, and a JWT would widen the endpoint from one project to every project its holder can read. See [Authentication](/get-started/authentication) for where keys live. > **Keep the key out of the config file** > > `.cursor/mcp.json`, `.vscode/mcp.json` and a project's `.codex/config.toml` live > in the repo and get committed. None of them needs the key inline: Cursor > interpolates `${env:VAR}`, VS Code prompts for a `${input:...}`, and Codex reads a > named environment variable - all three are used below. Issue a **dedicated key > per client** under Settings > API Keys so you can revoke one without breaking the > others. ## Connect a client ### Claude Code ```bash claude mcp add --transport http pug https://api.pugs.dev/mcp \ --header "Authorization: Bearer prv_YOUR_PRIVATE_KEY" ``` Run `/mcp` inside Claude Code to confirm the server connected and to list its tools. This writes to your own local config. Adding `--scope project` instead writes `.mcp.json` in the repo, which would commit the key - use an environment variable there, or keep the default. ### Codex ```bash codex mcp add pug --url https://api.pugs.dev/mcp \ --bearer-token-env-var PUG_PRIVATE_KEY ``` Or write it into `~/.codex/config.toml`, or `.codex/config.toml` for a single trusted project: ```toml [mcp_servers.pug] url = "https://api.pugs.dev/mcp" bearer_token_env_var = "PUG_PRIVATE_KEY" ``` Codex sends that variable's value as `Authorization: Bearer ...`. There is no inline token field (a literal key in the config is rejected), so export `PUG_PRIVATE_KEY` before launching Codex. ### Cursor In `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (this project only): ```json { "mcpServers": { "pug": { "url": "https://api.pugs.dev/mcp", "headers": { "Authorization": "Bearer ${env:PUG_PRIVATE_KEY}" } } } } ``` Cursor resolves `${env:...}` from the environment it was launched with, so export `PUG_PRIVATE_KEY` in your shell profile rather than pasting the key here. ### VS Code In `.vscode/mcp.json`. The `inputs` entry makes VS Code prompt for the key once and store it itself, so nothing secret lands in the file: ```json { "inputs": [ { "type": "promptString", "id": "pug-private-key", "description": "Pug private API key", "password": true } ], "servers": { "pug": { "type": "http", "url": "https://api.pugs.dev/mcp", "headers": { "Authorization": "Bearer ${input:pug-private-key}" } } } } ``` ### Any other client Anything that speaks streamable HTTP and can set a request header works. Point it at `https://api.pugs.dev/mcp` and add `Authorization: Bearer prv_...`. To check the URL and key without a client, ask the server for its tool list. The endpoint is stateless, so this works with no prior handshake: ```bash curl -X POST https://api.pugs.dev/mcp \ -H "Authorization: Bearer prv_YOUR_PRIVATE_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` A healthy response lists all twelve tools. `401` means the credential was refused; check the `prv_` prefix and that the key has not been revoked. `400` with a complaint about `Accept` means that header is missing a half: it must offer **both** `application/json` and `text/event-stream`, even though the reply comes back as JSON. The server ships its own guidance to the agent at initialize, so a client that surfaces MCP instructions already knows to discover the schema before querying, and which tools are project-wide versus per-user. ## Use with Claude connectors Claude's hosted surfaces (claude.ai, Desktop and mobile) have no config file to edit. They connect through a **custom connector** instead: add `https://api.pugs.dev/mcp` under **Add custom connector**, which lives in Organization settings > Connectors for a Team or Enterprise owner, and in Customize > Connectors otherwise. The credential goes in that dialog's **Request headers** section. Pug's key is a fixed credential rather than an OAuth identity, so this is the path - pick `x-api-key` from the header list and paste the key on its own: | Header | Value | |--------|-------| | `x-api-key` | `prv_YOUR_PRIVATE_KEY` | > **Claude sends the header value verbatim** > > It adds no scheme of its own. Under `Authorization` you would have to type > `Bearer ` and then the key, space included; entering the bare key there sends > `Authorization: prv_...`, which has no bearer scheme to parse and comes back > `401`. `x-api-key` carries no scheme to get wrong, which is why it is the one to > choose. > > Request-header authentication is in **beta** on Anthropic's side and still > rolling out. If the section is not in your dialog yet, ask Anthropic for access; > there is nothing to change on the Pug end. A request header is stored on the connector, not on a person, so everyone who can use that connector shares one key and reads the same project - and revoking the key disconnects all of them at once. For a team looking at its own analytics that is usually the intent; it is not a way to give each person their own scope. Pug is not in the Claude connectors directory. A directory listing requires OAuth 2.0, and `/mcp` authenticates with a static private key. ## Tools ### Discovery: call these first Filter schemas tell the agent which event kinds and property keys actually exist in your project, which is what keeps a query from being invented out of thin air. | Tool | Returns | |------|---------| | `get_insights_filter_schema` | Event kinds and the property keys/types available to filter and break down by | | `get_insights_property_values` | The observed values for one property key (e.g. every country seen for `$country`) | | `get_activity_filter_schema` | Alias of `get_insights_filter_schema` | | `get_activity_property_values` | Alias of `get_insights_property_values` | The `activity` pair is **not** a differently-scoped schema: both services hand the request to the same underlying call with the same arguments, so the two return identical data. They exist as four tools because each proto package declares the concept. Call whichever the agent reaches for. ### Project-wide questions | Tool | Returns | |------|---------| | `query_insights` | The main analysis tool: trends, funnel, retention, segmentation, user flow (Sankey), top-K and map, chosen by `insight_type`. Carries its own time range and granularity. See [Insights API](/api/insights) for the full spec | | `explore_events` | A paginated, filterable page of raw events across all users. Does not resolve aliases, so a merged anonymous id appears under the id that sent the event | ### One user at a time Start with a lookup: | Tool | Returns | |------|---------| | `get_profile` | One profile by its Pug profile id | | `get_profile_by_external_id` | One profile by the `external_id` your application assigned | The three per-user reads then take that profile's `distinct_id`. Each describes a **single** user and cannot answer an aggregate question - `query_insights` is for those. | Tool | Returns | |------|---------| | `get_activity_feed` | That user's events, newest first, filterable by session, time range and properties. Resolves aliases | | `get_activity_heatmap` | Per-day event counts for that user (last 60 days when no time range is given) | | `get_profile_stats` | First/last seen, total events, device, browser and location from the latest event, plus profile properties. Resolves aliases, so events from before the user identified are included | ### Compliance | Tool | Returns | |------|---------| | `get_deletion_request` | The status of a previously submitted erasure request: the DSAR audit trail | ## What is deliberately not exposed The tool surface is a curated subset, not everything the API can do: - **Profile erasure** (`Delete`, `DeleteDataSubject`): GDPR/DPDP erasure is irreversible and reaches the events table and every derived rollup. It stays a deliberate human action through the dashboard or the [Profiles API](/api/profiles), never an LLM-callable tool. `get_deletion_request` is read-only and remains available so an agent can report on a request someone else submitted. - **`SegmentUsers`**: the drill-down insight is still in progress; it is served as a Connect RPC but held off the tool surface until it lands. - **`List` (profiles)**: server-streaming, which MCP tools do not model. Use `explore_events` or a profile lookup instead. - **Everything under `sdk.*` and `dashboard.*`**: ingest, identity and management are out of scope. `/mcp` exposes the `shared.*` read surface only, which is why it is safe to hand an agent. The exposed set is pinned in the server: a generated tool with no policy entry, or a policy entry with no matching tool, fails startup rather than shipping a surprise tool. Renaming an RPC upstream breaks the build, not your agent. ## Limits and errors - **Two minutes per tool call.** A wide `query_insights` over a long window is the one that will hit it; narrow the time range or the breakdown. - **Time-range caps by granularity** apply exactly as they do on the [Insights API](/api/insights) - the same handler enforces them. - **Errors are Connect errors.** A rejected argument comes back as `invalid_argument` naming the field, an unknown profile as `not_found`. See the [error model](/api#error-model). - **A failed tool returns the whole error as JSON**: `code`, `message` and the `details` array. The correlation id is in there, as a `google.rpc.RequestInfo`; quote it in a support request. ## Self-hosting `/mcp` is mounted on every Pug deployment with no configuration to enable - if your server is up, the endpoint is up, on the same host and port as the rest of the API (`3000` by default, see [Configuration](/self-hosting/configuration)). Behind a reverse proxy, make sure `/mcp` is passed through and that the `Authorization` header survives the hop. Stripping it is the usual cause of a server that connects from `localhost` but 401s in production. ## Further reading - [Insights API](/api/insights): the query spec behind `query_insights` - [Profiles API](/api/profiles): profile lookups and erasure over HTTP - [Authentication](/get-started/authentication): issuing and revoking private keys - [API overview](/api): headers, base URLs and the Connect error model # Auto-properties > Every $-prefixed property the SDKs and server attach automatically, per platform. Source: https://docs.pug.sh/reference/auto-properties/ | Markdown: https://docs.pug.sh/reference/auto-properties/index.md Auto-properties are `$`-prefixed key-value pairs that Pug attaches to every event automatically. The SDK adds them on the client before the event leaves the device; the server adds a second layer during ingestion. **The `$` prefix is reserved.** Custom event properties you send must not start with `$` - the API rejects any event whose custom property keys begin with `$`. See [Events API](/api/events) for validation details. You do not need to set auto-properties yourself. Sending them manually has no effect for server-set properties (they are overwritten) and creates duplicates for SDK-set ones. ## SDK-set properties: Web Set by `@pug-sh/browser` inside `autoProperties` on every event, before the batch is sent to the API. | Property | Type | Description | Example | |---|---|---|---| | `$projectId` | string | Project identifier | `proj_abc123` | | `$platform` | string | SDK platform: always `web` | `web` | | `$url` | string | Full URL of the current page (`window.location.href`) | `https://app.example.com/pricing` | | `$referrer` | string | Document referrer (`document.referrer`); empty string when none | `https://google.com` | | `$pageTitle` | string | Page title (`document.title`), sent on `page_view` only | `Pricing - Acme` | | `$locale` | string | Browser locale (`navigator.language`) | `en-US` | | `$screenWidth` | string | Screen width in CSS pixels (`window.screen.width`) | `1920` | | `$screenHeight` | string | Screen height in CSS pixels (`window.screen.height`) | `1080` | | `$sdkVersion` | string | Web SDK version string | `0.1.0` | | `$browser` | string | Browser name, from `navigator.userAgentData` brands (Chromium) | `Google Chrome` | | `$browserVersion` | string | Browser major version string | `124` | | `$os` | string | Operating system / platform | `macOS` | | `$osVersion` | string | OS version (high-entropy, Chromium only) | `14.4.1` | | `$device` | string | Device model (high-entropy, Chromium only) | `Pixel 8` | | `$mobile` | string | `"true"` or `"false"`: whether the UA reports a mobile device | `"false"` | | `$utmSource` | string | `utm_source` query parameter | `google` | | `$utmMedium` | string | `utm_medium` query parameter | `cpc` | | `$utmCampaign` | string | `utm_campaign` query parameter | `spring_sale` | | `$utmContent` | string | `utm_content` query parameter | `banner_a` | | `$utmTerm` | string | `utm_term` query parameter | `analytics+software` | UTM parameters are parsed from `window.location.search` on every event call. `$browser`, `$browserVersion`, `$os`, `$osVersion`, `$device`, and `$mobile` are read from the [User-Agent Client Hints API](https://developer.mozilla.org/en-US/docs/Web/API/User-Agent_Client_Hints_API) (`navigator.userAgentData`) and are only populated in Chromium-based browsers. Firefox and Safari do not expose this API; those browsers get UA properties set by the server-side fallback (see [Server-set properties](#server-set-properties) below). `$platform` has no such fallback - it comes only from the SDK. Other Pug SDKs set the same key with their own value, so you can filter and break down by platform across them; see [Casing differences between SDKs](#casing-differences-between-sdks) for exactly what each one reports. See [Auto-tracking](/sdks#auto-tracking) for which events trigger automatically. ## SDK-set properties: Flutter Set by `pug_flutter` inside `autoProperties` on every event. All keys are camelCase. ### Core | Property | Type | Description | Example | |---|---|---|---| | `$projectId` | string | Project identifier | `proj_abc123` | | `$sdkVersion` | string | Flutter SDK version string | `0.0.4` | | `$platform` | string | `web` on Flutter web; otherwise the **lowercase** operating system: `android`, `ios`, `macos`, `linux`, `windows`, `fuchsia`, with anything else passed through as-is | `ios` | | `$deviceType` | string | Form factor: `tv`, `mobile` or `desktop`. Omitted rather than guessed on web (the host device isn't detectable from Dart) and on any target it can't classify | `mobile` | | `$os` | string | Operating system (`Platform.operatingSystem`). Equals `$platform` on native targets | `android` | | `$osVersion` | string | OS version string. Omitted entirely when the platform reports none | `14` | | `$locale` | string | Device locale as a BCP 47 language tag | `en-US` | | `$timezone` | string | IANA timezone identifier (from `flutter_timezone`; falls back to `DateTime.now().timeZoneName`'s abbreviation) | `America/New_York` | | `$url` | string | Current route name: set when a [`PugRouteObserver`](/sdks#auto-tracking) is wired | `/checkout` | | `$referrer` | string | Previous route name: set alongside `$url` on navigation | `/cart` | ### Screen | Property | Type | Description | Example | |---|---|---|---| | `$screenWidth` | integer | Logical screen width in dp | `390` | | `$screenHeight` | integer | Logical screen height in dp | `844` | | `$screenScale` | number | Device pixel ratio | `3.0` | ### App | Property | Type | Description | Example | |---|---|---|---| | `$appName` | string | App name from `PackageInfo` | `Acme App` | | `$appPackage` | string | Bundle / package identifier | `com.example.acme` | | `$appVersion` | string | App version string | `1.4.2` | | `$appBuild` | string | App build number | `42` | ### Device (populated per platform) | Property | Platform | Description | Example | |---|---|---|---| | `$deviceManufacturer` | Android, iOS, macOS, Linux | Device manufacturer or OEM | `Apple`, `Samsung` | | `$deviceModel` | Android, iOS, macOS, Linux, Windows | Device model name, falling back to the device identifier when the marketing-name lookup misses | `iPhone 15 Pro`, `MacBookPro18,3` | The user-assigned device name is deliberately **not** collected - it's PII. `$deviceType` (above) reports the form factor and is set on every native target, so prefer it over parsing `$deviceModel` when you only need phone-vs-desktop. ### Network | Property | Type | Description | Example | |---|---|---|---| | `$networkType` | string | Active connectivity type | `wifi`, `mobile`, `ethernet`, `vpn`, `bluetooth`, `other`, `none`, `unknown` | ### Campaign and click-ids Captured from deep-link URIs when `autoCaptureCampaigns` is enabled. Persisted until the next campaign URL is opened. | Property | Description | Example | |---|---|---| | `$utmSource` | `utm_source` query parameter | `google` | | `$utmMedium` | `utm_medium` query parameter | `cpc` | | `$utmCampaign` | `utm_campaign` query parameter | `spring_sale` | | `$utmTerm` | `utm_term` query parameter | `analytics+software` | | `$utmContent` | `utm_content` query parameter | `banner_a` | | `$gclid` | Google Click ID | `Cj0KCQjw...` | | `$fbclid` | Meta (Facebook) Click ID | `IwAR3x...` | | `$msclkid` | Microsoft Advertising Click ID | `abc123` | | `$ttclid` | TikTok Click ID | `Cg8IEAA...` | See [Auto-tracking](/sdks#auto-tracking) for the navigation events (`screen_view` on mobile, `page_view` on web) emitted when a `PugRouteObserver` is wired. ## SDK-set properties: Node Set by `@pug-sh/node` inside `autoProperties` on every event, before the batch is sent to the API. | Property | Type | Description | Example | |---|---|---|---| | `$lib` | string | Library identifier: always `pug-node` | `pug-node` | | `$sdkVersion` | string | Node SDK version string | `0.0.5` | | `$platform` | string | SDK platform: always `server` | `server` | That is the complete set. The server SDK has no browser, device, or ambient user, so it sets none of the page, screen, locale, or campaign properties the client SDKs collect - pass anything you need as a custom property on `track()` instead. It also omits `$projectId`: the private key (`prv_...`) it authenticates with is already project-scoped, so there is no project ID to supply at init. Server-set properties are still added during ingestion, but they describe **your backend rather than your end user** - the request reaches the API from your server, so `$ip` and every geo property derived from it resolve to your server's address. Filter on `$platform = "server"` to keep server events out of geo and browser breakdowns. ## Casing differences between SDKs Every Pug SDK sets `$platform`, which makes it the one key common to all of them - filter or break down on it to separate traffic by SDK. The values are **lowercase across every SDK** so a platform breakdown reconciles between them; the backend promotes `$platform` into its own column verbatim, so a casing mismatch would split one platform into per-SDK buckets. > **pug_flutter changed these values in 0.0.3** > > Before 0.0.3 the Flutter SDK sent `defaultTargetPlatform.name`, which kept the enum's casing - `iOS` and `macOS` where every other SDK sends `ios` and `macos`. Flutter **web** also reported the browser's host OS, making a web app on an iPhone indistinguishable from native iOS traffic. Saved segments, filters or dashboards still matching the old values need updating to the lowercase set. | SDK | `$platform` value | Example | |---|---|---| | `@pug-sh/browser` | always `web` | `web` | | `pug_flutter` | `web` on Flutter web, else the lowercase OS | `android`, `ios`, `macos` | | `@pug-sh/node` | always `server` | `server` | Beyond that key, the Web SDK and Flutter SDK share several property names with identical casing. The table below calls out any divergence so you can write cross-platform queries correctly. **"not set"** = the SDK could supply this but doesn't (a real gap on that platform); **"not applicable"** = the concept doesn't exist on that platform. The Node SDK is left out: it sets only the three properties listed in its section above. | Concept | Web SDK property | Flutter SDK property | |---|---|---| | URL / screen name | `$url` (full URL) | `$url` (current route name; set when `PugRouteObserver` is wired) | | Page / screen title | `$pageTitle` (on `page_view` only) | not set | | Referrer | `$referrer` | `$referrer` (previous route name) | | Platform | `$platform` (always `web`) + `$os` (platform string) | `$platform` (`web`, or the lowercase OS) + `$os` (equals `$platform` natively) | | OS version | `$osVersion` | `$osVersion` | | Browser | `$browser` | not set | | Browser version | `$browserVersion` | not set | | Device model | `$device` | `$deviceModel` | | Form factor | `$mobile` (string `"true"`/`"false"`) | `$deviceType` (`tv`, `mobile` or `desktop`; omitted on web) | | Screen width | `$screenWidth` | `$screenWidth` | | Screen height | `$screenHeight` | `$screenHeight` | | Locale | `$locale` | `$locale` | | Timezone | not set client-side | `$timezone` | | UTM source | `$utmSource` | `$utmSource` | | UTM medium | `$utmMedium` | `$utmMedium` | | UTM campaign | `$utmCampaign` | `$utmCampaign` | | UTM term | `$utmTerm` | `$utmTerm` | | UTM content | `$utmContent` | `$utmContent` | | Click-ids | not set | `$gclid`, `$fbclid`, `$msclkid`, `$ttclid` | | App info | not applicable | `$appName`, `$appPackage`, `$appVersion`, `$appBuild` | | Network type | not set | `$networkType` | ## Server-set properties Added by the ingestion server during `BatchCreate`, after the SDK payload arrives. These properties are **never present in the SDK payload** - values sent by clients are ignored or actively stripped. ### Geo enrichment (Cloudflare) Geo properties come from Cloudflare CDN headers injected at the edge. They are populated only when the API is fronted by Cloudflare with the **"Add visitor location headers"** Managed Transform enabled. Behind other proxies or in self-hosted setups without Cloudflare, these properties will be absent. | Property | Description | Example | |---|---|---| | `$ip` | Client IP address (from `CF-Connecting-IP`, then `True-Client-IP`, then `X-Forwarded-For`) | `203.0.113.1` | | `$continent` | Continent code | `NA` | | `$country` | ISO 3166-1 alpha-2 country code | `US` | | `$region` | Region or state name | `California` | | `$city` | City name | `San Francisco` | | `$postalCode` | Postal / ZIP code | `94107` | | `$metroCode` | Metro code (US DMA) | `807` | | `$latitude` | Latitude (float64) | `37.7749` | | `$longitude` | Longitude (float64) | `-122.4194` | | `$timezone` | IANA timezone name from Cloudflare | `America/Los_Angeles` | ### User-agent enrichment (server-side fallback) For browsers that do not expose the User-Agent Client Hints API (Firefox, Safari, all iOS browsers), the server parses the `User-Agent` request header and sets these properties. For Chromium browsers the SDK supplies these values directly and they take precedence. | Property | Description | Example | |---|---|---| | `$browser` | Browser family name | `Firefox` | | `$browserVersion` | Browser version string | `126.0` | | `$os` | Operating system name | `Mac OS X` | | `$osVersion` | OS version string | `10.15.7` | | `$device` | Device family | `iPhone` | | `$mobile` | `"true"` or `"false"` | `"false"` | ### Bot signals (Cloudflare) `$bot_score` and `$verified_bot` are **server-only properties**. Any values sent by the client are stripped before enrichment begins; the server then sets them from Cloudflare's `CF-Bot-Score` and `CF-Verified-Bot` headers. | Property | Type | Description | Example | |---|---|---|---| | `$bot_score` | integer | Cloudflare bot score (0-99; lower = more likely bot) | `2` | | `$verified_bot` | boolean | `true` if Cloudflare identified a known good bot | `false` | These properties are only populated when Cloudflare is in the request path. In self-hosted deployments without Cloudflare, they will be absent. ## Using auto-properties in queries Reference any auto-property in Insights filters and breakdowns exactly like custom properties, using the exact key string including the `$` prefix. **Breakdown by country:** **Filter to Chrome users only:** **UTM campaign performance:** **Exclude bots:** ## Related - [Events API](/api/events): event validation rules including the `$`-prefix restriction on custom properties - [Auto-tracking](/sdks#auto-tracking): what each SDK captures automatically (Web interactions; Flutter navigation + app lifecycle) # Glossary > Quick definitions for every term used across the Pug docs, SDKs and API. Source: https://docs.pug.sh/reference/glossary/ | Markdown: https://docs.pug.sh/reference/glossary/index.md Quick reference for terms used across the Pug docs, SDKs, and API. Definitions are kept concise - follow the linked pages for full explanations. | Term | Definition | |------|------------| | **Organization** | Top-level tenant. Owns members, billing, and email configuration. Projects belong to an organization. See [Core concepts](/get-started/concepts). | | **Project** | Isolated data boundary inside an organization. Owns events, profiles, insights, and API keys. All analytics queries are project-scoped. See [Core concepts](/get-started/concepts). | | **Public key** (`pub_...`) | Project-scoped write credential for client SDKs. Sent as `x-api-key`; lets you ingest events and identify users, but cannot read analytics. Safe to ship in a browser bundle or mobile binary. See [Authentication](/get-started/authentication). | | **Private key** (`prv_...`) | Project-scoped read/write credential for trusted server-side code. Sent as `x-api-key`; reaches `shared.*` services (insights, profiles). Never ship in client code. See [Authentication](/get-started/authentication). | | **Event** | A named occurrence with custom properties and a timestamp: the atomic unit of analytics data. The event name is its `kind`; names starting with `pug.` are reserved. See [Events API](/api/events). | | **Property** | A custom key/value pair attached to an event (string, integer, double, boolean, or timestamp). Keys must not start with `$`. Sent in `customProperties` on each event. | | **Auto-property** | A `$`-prefixed property attached automatically by the SDK or server - for example `$url`, `$browser`, `$country`. `$`-prefixed keys are reserved and cannot be set as custom properties. See [Auto-properties](/reference/auto-properties). | | **Well-known event** | A standard event name (e.g. `page_view`, `purchase`) that carries a typed schema. Custom event names not in the registry are also accepted without schema validation. See [Well-known events](/reference/well-known-events). | | **Profile** | A persistent record representing one user (identified or anonymous) with an accumulated set of traits (properties). Created or updated by `identify()`. See [Profiles API](/api/profiles). | | **Anonymous ID** | An SDK-generated identifier in the form `anon-...`, created when a visitor first arrives. Events are attributed to this ID until the user is identified. | | **External ID** | Your stable user identifier (e.g. a database user ID), passed as `external_id` to `Identify`. Use a value that does not change - not an email address. | | **Distinct ID** | The identifier an event is attributed to: the anonymous ID before `identify()` is called, the external ID after. Stored as `distinct_id` on every event. | | **Alias** | The association of multiple identifiers with one profile: most commonly the anonymous-to-identified merge on the first `identify()` call (there is no separate `alias()` method). See [Profiles API](/api/profiles). | | **Session** | A window of activity grouped by the SDK. A new session starts after the idle timeout (default 30 min) or the maximum session duration (default 24 h). The session ID is synced across browser tabs. See [Core concepts](/get-started/concepts). | | **Insight** | A server-side analytics query specification: event selection, filters, time range, granularity, aggregation, and optional breakdowns. The same spec powers dashboard charts and the `InsightsService` API. Types: `TRENDS`, `SEGMENTATION`, `FUNNEL`, `RETENTION`, `USER_FLOW`, `TOP_K`. See [Insights API](/api/insights). | | **Connect RPC** | The API transport protocol used by all Pug services. Supports the Connect, gRPC, and gRPC-Web protocols over HTTP/1.1 and HTTP/2. Request path format: `/./`. See [Authentication](/get-started/authentication) and the [API overview](/api). | | **Batch** | A single `BatchCreate` call carrying up to 1 000 events. All event ingestion goes through this endpoint - there is no single-event API. See [Events API](/api/events). | | **Tracking consent** | The Web SDK's three-state capture gate - `granted`, `cookieless` (events flow, nothing stored on the device), or `denied`. Defaults to `cookieless`. See [Tracking consent](/sdks#tracking-consent). | | **Cookieless ID** | The `cookieless-`-prefixed distinct ID ingest derives for events sent without identity - an HMAC over project, IP and user agent, keyed by a salt that rotates daily. Never stored on the device, never client-settable, and excluded from user-counting metrics by default. See [Cookieless events](/api/events#cookieless-events). | ## Further reading - [Core concepts](/get-started/concepts): detailed explanations of key terms - [Authentication](/get-started/authentication): keys, headers, and error codes - [Profiles API](/api/profiles): identity lifecycle, anonymous -> identified merge - [Insights API](/api/insights): query types and spec format - [Auto-properties](/reference/auto-properties): full `$`-prefixed property catalog # RPC services > Complete index of Connect RPC services by package family, with the auth mode each one requires. Source: https://docs.pug.sh/reference/rpc-services/ | Markdown: https://docs.pug.sh/reference/rpc-services/index.md Complete index of Connect RPC services in the Pug API, grouped by package family. Each family shares an auth mode; see the [API overview](/api) for request headers and the base URL. Endpoint pattern: `POST https://api.pugs.dev/{package}.{Service}/{Method}` ## `public.*`: no authentication Open endpoints. No API key or JWT required. | Package | Service | Purpose | |---------|---------|---------| | `public.auth.v1` | `AuthService` | Dashboard sign-in: email/password, magic link request and completion | | `public.dashboards.v1` | `SharedDashboardsService` | Read a publicly-shared dashboard (`Query`). Authorized by the unguessable **share token** in the request, not by a credential | ## `sdk.*`: SDK key (`x-api-key`) Client-side write access. Safe to embed in browsers and mobile apps. Use your project's **public** API key. | Package | Service | Purpose | Docs | |---------|---------|---------|------| | `sdk.events.v1` | `EventsService` | Ingest event batches (`BatchCreate`) | [Events API](/api/events) | | `sdk.profiles.v1` | `ProfilesSDKService` | Create or update profiles, merge anonymous IDs (`Identify`) | [Profiles API](/api/profiles) | | `sdk.devices.v1` | `DevicesService` | Push-notification device registration (`Subscribe`, `UpdateStatus`, `UpdateToken`) | _out of scope for these docs_ | ## `shared.*`: private API key or JWT (`x-api-key: prv_...` or `Authorization: Bearer`) Server-side or trusted integrations. Accepts either a **private** API key or a dashboard JWT. The read-only methods of `shared.insights`, `shared.activity` and `shared.profiles` are additionally exposed as [MCP tools](/mcp) at `POST /mcp` - same handlers, same private-key auth. Erasure and streaming methods are not. | Package | Service | Purpose | Docs | |---------|---------|---------|------| | `shared.insights.v1` | `InsightsService` | Run analytics queries (`Query`, `SegmentUsers`, `GetFilterSchema`, `GetPropertyValues`) | [Insights API](/api/insights) | | `shared.profiles.v1` | `ProfilesService` | Read, list, and delete profiles (`Get`, `GetByExternalId`, `List`, `Delete`), plus GDPR/DPDP data-subject erasure (`DeleteDataSubject`, `GetDeletionRequest`) | [Profiles API](/api/profiles) | | `shared.activity.v1` | `ActivityService` | Per-profile activity feed, event explorer, heatmap, and profile stats | - | | `shared.campaigns.v1` | `CampaignService` | Push-campaign management (`BatchGet`, `Create`, `Get`, `Update`, `Delete`) | _out of scope for these docs_ | | `shared.delivery.v1` | `DeliveryService` | Push-delivery event recording (`RecordEvent`) | _out of scope for these docs_ | ## `dashboard.*`: JWT (`Authorization: Bearer`) Browser sessions for the Pug dashboard. Not intended for programmatic API access. Scoping headers (e.g. `x-project-id`) are described in [Authentication](/get-started/authentication). | Package | Service | Purpose | |---------|---------|---------| | `dashboard.orgs.v1` | `OrgsService` | Organization CRUD and member management | | `dashboard.projects.v1` | `ProjectsService` | Project CRUD and settings | | `dashboard.customers.v1` | `CustomersService` | Caller's own account (`GetMe`, `SetPassword`) | | `dashboard.dashboards.v1` | `DashboardsService` | Dashboard and tile management, dashboard query execution | | `dashboard.orgemailproviders.v1` | `OrgEmailProvidersService` | Organization email provider configuration (`Get`, `Set`, `Remove`, `SendTest`) | ## `common.*`: shared message types only These packages define reusable message and enum types imported by the service packages above. They do **not** define any RPC services. | Package | Contents | |---------|---------| | `common.v1` | `PropertyValue`, `TimeRange`, `TimeRangePreset`, filter types, filter schema types | | `common.events.v1` | Well-known event schemas (navigation, auth, commerce, media, and more) | ## `workers.*`: internal only These packages define NATS message envelopes used between internal backend workers. They are not Connect RPC services and are not client-facing. | Package | Contents | |---------|---------| | `workers.email.v1` | Email job message types (`OrgMemberInvitePayload`, `MagicLinkPayload`) | | `workers.profiles.v1` | Profile worker message types (`ProfileAliasMessage`, `ProfileUpsertMessage`) | | `workers.compliance.v1` | Data-subject erasure job (`EraseMessage`) - drives the compliance worker behind `DeleteDataSubject` | ## Further reading - [API overview](/api) - [MCP server](/mcp) - [Authentication](/get-started/authentication) - [Events API](/api/events) - [Profiles API](/api/profiles) - [Insights API](/api/insights) # Well-known events > The typed event catalog - standard event names that carry validated schemas and richer dashboards. Source: https://docs.pug.sh/reference/well-known-events/ | Markdown: https://docs.pug.sh/reference/well-known-events/index.md Well-known events are the predefined event names Pug recognizes, with typed property schemas enforced at ingestion. The catalog holds **127 events across 19 families**, listed in full below. Every SDK type-checks them, but the call shape differs: | SDK | Call | Available | |-----|------|-----------| | **Flutter** | `Pug.track.purchase(productId: ..., amount: ...)` - a generated method per event | all 127 | | **Web** | `track('purchase', { productId, amount })`: the name narrows the property type | 119 (events flagged for other platforms are excluded) | | **Node** | `pug.track(distinctId, 'purchase', { ... })` | 114 | | **HTTP** | `kind: "purchase"` with wrapped `customProperties` | all 127 | Either way the check is at compile time, so an unknown property or a wrong type is an error before the event leaves the device. Custom event names (any snake\_case string other than a `pug.`-prefixed kind) are accepted without schema validation. See [Events API](/api/events) for the `pug.` prefix restriction and validation error format. **Further reading:** [Tracking events](/sdks#track-events) - sending custom and well-known events from each SDK ## Commerce Product browsing, cart, checkout, and purchase events. `price` is a single-item price; `amount` is a transaction total. `currency` is an uppercase ISO 4217 code (e.g. `USD`, `EUR`). Money fields use the major currency unit (e.g. dollars, not cents). | Event | Properties | Description | |---|---|---| | `product_viewed` | `product_id`\* string, `product_name` string, `category` string, `brand` string, `sku` string, `price` double, `currency` string | User viewed a product detail page | | `product_list_viewed` | `list_id`\* string, `list_name` string, `category` string, `item_count` int | User viewed a product listing, category, or search-results page | | `add_to_cart` | `product_id`\* string, `price` double, `currency` string, `cart_id` string, `quantity` int, `category` string, `brand` string, `sku` string | Item added to cart | | `remove_from_cart` | `product_id`\* string, `price` double, `currency` string, `cart_id` string, `quantity` int, `category` string, `brand` string, `sku` string | Item removed from cart | | `cart_viewed` | `cart_id` string, `item_count` int, `amount` double, `currency` string | User opened the cart | | `wishlist_added` | `product_id`\* string, `wishlist_id` string, `price` double, `currency` string | Item added to wishlist | | `wishlist_removed` | `product_id`\* string, `wishlist_id` string | Item removed from wishlist | | `coupon_applied` | `coupon_id`\* string, `coupon_code` string, `cart_id` string, `discount_amount` double, `currency` string | Coupon applied to cart | | `coupon_removed` | `coupon_id`\* string, `coupon_code` string, `cart_id` string, `reason` string | Coupon removed from cart | | `checkout_started` | `product_id` string, `amount` double, `currency` string, `cart_id` string, `checkout_id` string, `item_count` int | User initiated checkout | | `checkout_step_completed` | `checkout_id`\* string, `step`\* string, `step_index` int | A checkout step was completed | | `purchase` | `product_id` string, `amount` double, `currency` string, `order_id` string, `quantity` int, `category` string, `brand` string, `sku` string | Order completed (fires once per successful order after payment is confirmed) | | `order_refunded` | `order_id`\* string, `amount` double, `currency` string, `reason` string | Order was refunded | ## Authentication Sign-up, sign-in, sign-out, email verification, password reset, and MFA lifecycle. All auth events have no required properties beyond the event kind; common method values are `"email"`, `"google"`, `"github"`, `"totp"`, `"sms"`, `"webauthn"`, `"backup_codes"`. | Event | Properties | Description | |---|---|---| | `signup` | - | User completed registration | | `signin` | - | User signed in | | `signout` | - | User signed out | | `email_verified` | - | Email address was verified | | `password_reset_requested` | - | Password reset was requested | | `password_reset_completed` | - | Password reset flow completed | | `mfa_enabled` | `method` string | MFA was enabled | | `mfa_disabled` | `method` string | MFA was disabled | ## App Lifecycle Native mobile lifecycle events from the Flutter SDK (iOS and Android) - the Web SDK does not emit these. The SDK auto-emits only `app_open` and `app_close` (plus `screen_view` for navigation); the rest are typed events you track manually. | Event | Properties | Description | |---|---|---| | `app_open` | - | App launched or brought to the foreground | | `app_close` | - | App moved to the background or closed | | `app_install` | `app_version` string, `install_source` string | First launch after a fresh install | | `app_update` | `app_version`\* string, `previous_version` string | First launch after an app update | | `app_backgrounded` | - | App moved to the background | | `app_foregrounded` | - | App returned to the foreground | | `app_crashed` | `error_message` string, `error_type` string | Unhandled crash (also available on desktop) | | `screen_view` | `screen_name`\* string, `screen_class` string | A native screen was viewed: the mobile equivalent of `page_view` | ## App Usage Cross-platform - available in every SDK. | Event | Properties | Description | |---|---|---| | `feature_used` | `feature_id`\* string, `feature_name` string | A named feature was used | ## Billing Subscription, invoice, payment, trial, and refund lifecycle. `currency` is an uppercase ISO 4217 code; `amount` is the transaction total in the major currency unit. | Event | Properties | Description | |---|---|---| | `subscription_started` | `subscription_id`\* string, `plan_id` string, `amount` double, `currency` string | New subscription was created | | `subscription_changed` | `subscription_id`\* string, `previous_plan_id` string, `new_plan_id` string | Subscription plan was changed | | `subscription_canceled` | `subscription_id`\* string, `plan_id` string, `reason` string | Subscription was canceled | | `subscription_renewed` | `subscription_id`\* string, `plan_id` string, `amount` double, `currency` string | Subscription renewed | | `subscription_paused` | `subscription_id`\* string, `plan_id` string, `reason` string | Subscription was paused | | `subscription_resumed` | `subscription_id`\* string, `plan_id` string, `reason` string | Paused subscription was resumed | | `subscription_trial_will_end` | `subscription_id`\* string, `plan_id` string, `trial_id` string | Trial is nearing expiry (fires in advance, e.g. 3 days before) | | `trial_started` | `trial_id` string, `plan_id` string | Free trial started | | `trial_converted` | `trial_id` string, `subscription_id` string, `plan_id` string | Trial converted to paid subscription | | `invoice_paid` | `invoice_id`\* string, `subscription_id` string, `amount` double, `currency` string | Invoice was paid | | `invoice_failed` | `invoice_id`\* string, `subscription_id` string, `amount` double, `currency` string, `reason` string | Invoice payment failed | | `payment_succeeded` | `payment_id` string, `invoice_id` string, `subscription_id` string, `amount` double, `currency` string | Payment succeeded | | `payment_failed` | `payment_id` string, `invoice_id` string, `subscription_id` string, `amount` double, `currency` string, `reason` string | Payment failed | | `payment_method_added` | `payment_method_id` string, `payment_method_type` string | Payment method was added | | `payment_method_removed` | `payment_method_id` string, `payment_method_type` string | Payment method was removed | | `refund_failed` | `order_id`\* string, `refund_id` string, `amount` double, `currency` string, `reason` string | Refund attempt failed | ## Chat Conversation lifecycle, message delivery, membership, calls, attachments, and reactions. `conversation_id` is the durable chat identifier. `member_id` is the conversation-membership ID, not the SDK `distinct_id`. Common values: `conversation_type`: `"dm"`, `"group"`, `"channel"`, `"broadcast"`; `message_type`: `"text"`, `"image"`, `"video"`, `"audio"`, `"file"`, `"system"`. `duration`-typed props (e.g. `mute_duration`) serialize as `{ "seconds": N, "nanos": N }`; see [Notes](#notes). | Event | Properties | Description | |---|---|---| | `chat_created` | `conversation_id`\* string, `conversation_type` string, `participant_count` int | Conversation was created | | `chat_joined` | `conversation_id`\* string, `conversation_type` string | User joined a conversation | | `chat_left` | `conversation_id`\* string, `conversation_type` string, `reason` string | User left a conversation | | `chat_deleted` | `conversation_id`\* string, `conversation_type` string, `reason` string | Conversation was deleted | | `chat_archived` | `conversation_id`\* string, `conversation_type` string | Conversation was archived | | `chat_unarchived` | `conversation_id`\* string, `conversation_type` string | Conversation was unarchived | | `chat_member_added` | `conversation_id`\* string, `member_id`\* string, `role` string | Member was added to a conversation | | `chat_member_removed` | `conversation_id`\* string, `member_id`\* string, `reason` string | Member was removed from a conversation | | `chat_member_role_changed` | `conversation_id`\* string, `member_id`\* string, `previous_role` string, `new_role`\* string | Member's role was changed | | `chat_member_muted` | `conversation_id`\* string, `member_id`\* string, `mute_duration` duration | Member was muted (duration omitted for indefinite mutes) | | `chat_user_blocked` | `user_id`\* string | User was blocked (not conversation-scoped) | | `chat_message_sent` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `message_type` string, `character_count` int, `attachment_count` int, `thread_id` string, `parent_message_id` string | Message was sent | | `chat_message_received` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `message_type` string, `character_count` int, `attachment_count` int, `thread_id` string, `parent_message_id` string | Message was received | | `chat_message_failed` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `reason`\* string, `thread_id` string | Message send failed | | `chat_message_read` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `thread_id` string | Message was read | | `chat_message_deleted` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `reason` string, `thread_id` string | Message was deleted | | `chat_message_edited` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `thread_id` string | Message was edited | | `chat_message_pinned` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `thread_id` string | Message was pinned | | `chat_message_unpinned` | `conversation_id`\* string, `message_id` string, `conversation_type` string, `thread_id` string | Message was unpinned | | `chat_typing_started` | `conversation_id`\* string, `conversation_type` string | User started typing (typer is the SDK `distinct_id`) | | `chat_typing_stopped` | `conversation_id`\* string, `conversation_type` string | User stopped typing | | `chat_attachment_uploaded` | `conversation_id`\* string, `message_id` string, `attachment_id`\* string, `attachment_type` string, `size_bytes` int64, `thread_id` string | Attachment uploaded in chat | | `chat_attachment_downloaded` | `conversation_id`\* string, `message_id` string, `attachment_id`\* string, `attachment_type` string, `size_bytes` int64, `thread_id` string | Attachment downloaded from chat | | `chat_call_started` | `conversation_id`\* string, `call_id`\* string, `call_type` string | Call was started | | `chat_call_joined` | `conversation_id`\* string, `call_id`\* string, `call_type` string | User joined a call | | `chat_call_left` | `conversation_id`\* string, `call_id`\* string, `call_type` string, `duration` duration | User left a call | | `chat_call_screen_shared` | `conversation_id`\* string, `call_id`\* string | Screen sharing started on a call | | `chat_call_recording_started` | `conversation_id`\* string, `call_id`\* string | Call recording started | | `chat_reaction_added` | `conversation_id`\* string, `message_id` string, `reaction`\* string, `thread_id` string | Reaction added to a message | | `chat_reaction_removed` | `conversation_id`\* string, `message_id` string, `reaction`\* string, `thread_id` string | Reaction removed from a message | ## Discovery / Search Search queries, results, recommendations, filters, and sorting. These events capture how users explore and find content. | Event | Properties | Description | |---|---|---| | `search` | `query`\* string | Search query submitted | | `search_result_clicked` | `query`\* string, `result_id`\* string, `index` int | User clicked a search result | | `recommendation_viewed` | `recommendation_id`\* string, `item_id` string, `source` string, `index` int | Recommendation impression | | `recommendation_clicked` | `recommendation_id`\* string, `item_id`\* string, `source` string, `index` int | User clicked a recommendation | | `filter_applied` | `key`\* string, `value` string | Filter was applied | | `sort_changed` | `key`\* string, `direction` string | Sort order was changed | ## Error Application errors and exceptions. `(PII)` marks fields handled under the project's scrubbing and data-residency rules (see [Notes](#notes)). | Event | Properties | Description | |---|---|---| | `error_occurred` | `error_code`\* string, `message` string (PII), `severity` string, `unhandled` bool, `stack` string (PII) | An error was caught or escaped to a global handler. Common `severity` values: `"info"`, `"warning"`, `"error"`, `"fatal"`. Set `unhandled: true` when the error escaped application code | ## File File upload, download, and export events. | Event | Properties | Description | |---|---|---| | `file_uploaded` | `file_id`\* string, `file_name` string (PII), `file_type` string, `size_bytes` int64 | File was uploaded | | `file_downloaded` | `file_id`\* string, `file_name` string (PII), `file_type` string, `size_bytes` int64 | File was downloaded | | `export_started` | `export_id`\* string, `export_type` string | Data export was initiated | | `export_completed` | `export_id`\* string, `export_type` string, `size_bytes` int64 | Data export completed | ## Form Form interaction start and submission. | Event | Properties | Description | |---|---|---| | `form_start` | `form_id`\* string, `form_name` string | User began filling in a form | | `form_submit` | `form_id`\* string, `form_name` string, `action` string | Form was submitted | ## Integration Third-party integration connect and disconnect. Common `integration_type` values: `"slack"`, `"github"`, `"linear"`, `"salesforce"`, `"hubspot"`, `"zapier"`, `"webhook"`. | Event | Properties | Description | |---|---|---| | `integration_connected` | `integration_id` string, `integration_type`\* string | Integration was connected | | `integration_disconnected` | `integration_id` string, `integration_type`\* string, `reason` string | Integration was disconnected | ## Invitation Workspace invitation sent and accepted. | Event | Properties | Description | |---|---|---| | `invite_sent` | `invite_id` string, `workspace_id` string, `inviter_id` string, `invitee_id` string, `invitee_email` string (PII), `role` string | Invitation was sent | | `invite_accepted` | `invite_id` string, `workspace_id` string, `inviter_id` string, `invitee_id` string, `invitee_email` string (PII), `role` string | Invitation was accepted | ## Media Video and audio playback events. `video_started` / `audio_started` fire on first play of a session; `video_play` / `audio_play` fire on resume from pause. `position`, `from_position`, and `to_position` are durations from the start of the media. | Event | Properties | Description | |---|---|---| | `video_started` | `video_id`\* string | Video playback started for the first time in this session | | `video_play` | `video_id`\* string, `position` duration | Video resumed from pause | | `video_pause` | `video_id`\* string, `position` duration | Video was paused | | `video_seeked` | `video_id`\* string, `from_position` duration, `to_position` duration | User seeked in video | | `video_completed` | `video_id`\* string | Video playback reached the end | | `audio_started` | `audio_id`\* string | Audio playback started for the first time in this session | | `audio_play` | `audio_id`\* string, `position` duration | Audio resumed from pause | | `audio_pause` | `audio_id`\* string, `position` duration | Audio was paused | | `audio_seeked` | `audio_id`\* string, `from_position` duration, `to_position` duration | User seeked in audio | | `audio_completed` | `audio_id`\* string | Audio playback reached the end | ## Navigation Page views, clicks, scroll depth, and frustration signals. Page URL, referrer, and UTM parameters are provided as [auto-properties](/reference/auto-properties) (`$url`, `$referrer`, `$utmSource`/`$utmMedium`/`$utmCampaign`/...) on every event, and the page title as `$pageTitle` on `page_view` only - none of them appear as typed properties here. On `click` and `dead_click`, `text` is the clicked element's [own text](/sdks#redacting-pii), not its whole subtree. | Event | Properties | Description | |---|---|---| | `page_view` | - | Web page navigation or SPA route change (auto-tracked by Web SDK) | | `click` | `class` string, `id` string, `tag` string, `text` string, `x` int, `y` int | Element click: Web only | | `rage_click` | `click_count` int (>=2), `element` string, `x` int, `y` int | Multiple rapid clicks on the same non-responsive target: Web only | | `dead_click` | `element` string, `text` string, `x` int, `y` int | Click with no observable response: Web only | | `scroll` | `percent` int (0-100), `scroll_y` int | Scroll depth milestone: Web and mobile | ## Notification Notification receipt, click, and dismissal. `notification_type` describes the delivery channel: `"push"`, `"email"`, `"in_app"`, `"sms"`. | Event | Properties | Description | |---|---|---| | `notification_received` | `campaign_id`\* string, `notification_type` string | Notification was received by the client | | `notification_clicked` | `campaign_id`\* string, `notification_type` string | User clicked a notification | | `notification_dismissed` | `campaign_id`\* string, `notification_type` string | User dismissed a notification | ## Social | Event | Properties | Description | |---|---|---| | `share` | - | User shared content | ## Support Feedback, NPS, surveys, support tickets, live chat, and help articles. | Event | Properties | Description | |---|---|---| | `feedback_submitted` | `feedback_id` string, `category` string, `comment` string (PII) | Feedback was submitted | | `nps_submitted` | `score` int (0-10), `comment` string (PII) | NPS score was submitted | | `survey_started` | `survey_id`\* string | Survey was started | | `survey_completed` | `survey_id`\* string, `question_count` int | Survey was completed | | `support_ticket_created` | `ticket_id`\* string, `category` string, `priority` string | Support ticket was opened | | `support_ticket_resolved` | `ticket_id`\* string, `resolution` string (PII) | Support ticket was resolved | | `support_chat_started` | `conversation_id`\* string, `topic` string (PII) | Support chat session started | | `help_article_viewed` | `article_id`\* string, `article_title` string, `category` string | Help article was viewed | ## Workspace Workspace creation, membership, role changes, and settings. | Event | Properties | Description | |---|---|---| | `workspace_created` | `workspace_id`\* string, `workspace_name` string | Workspace was created | | `workspace_joined` | `workspace_id`\* string, `role` string | User joined a workspace | | `workspace_deleted` | `workspace_id`\* string, `reason` string | Workspace was deleted | | `workspace_role_changed` | `workspace_id`\* string, `member_id`\* string, `previous_role` string, `new_role`\* string | Member's workspace role was changed | | `workspace_settings_updated` | `workspace_id`\* string, `setting`\* string | A workspace setting was changed | ## API API key management events. | Event | Properties | Description | |---|---|---| | `api_key_created` | `api_key_id` string, `name` string, `scope` string | API key was created | | `api_key_revoked` | `api_key_id` string, `name` string, `reason` string | API key was revoked | ## Notes **Required props** are marked with \*; the API returns `invalid_argument` if a required property is missing or the wrong type. Optional props may be omitted. **PII-tagged props** are labelled `(PII)` above. Fields tagged PII in the schema are subject to the project's data-residency and scrubbing rules. **`duration` type**: duration properties (e.g. `position`, `mute_duration`, `duration`) are serialized as a JSON object `{ "seconds": N, "nanos": N }`. **`int64` type**: the Web SDK types these as `bigint`, so write the literal with an `n` suffix: `track('file_uploaded', { fileId: 'f1', sizeBytes: 1024n })`. A plain `number` is a compile error, and never encoded correctly. Over raw HTTP an int64 is a **JSON string**; see [Track events](/sdks#track-events). **`pug.` prefix is reserved**: custom event kind strings must not start with `pug.`. The API rejects any custom event whose `kind` begins with that prefix. See [Events API](/api/events). **Web vs Flutter**: the Web SDK exposes all events listed here except the mobile app lifecycle events (`app_open`, `app_close`, `app_install`, `app_update`, `app_backgrounded`, `app_foregrounded`, `app_crashed`, `screen_view`). The Flutter SDK includes those and drops the DOM-interaction events (`click`, `rage_click`, `dead_click`). Its navigation event is `screen_view` on mobile; on **web** targets Flutter emits `page_view` instead. # Roadmap > Where Pug is headed - shipped today, and the privacy, SDK and engagement work coming next. Source: https://docs.pug.sh/roadmap/ | Markdown: https://docs.pug.sh/roadmap/index.md Where Pug is headed. This is a **directional** view - the shape of what we're building next, not a dated commitment. Everything under [Available today](#available-today) is shipped and documented; the rest is planned and lands here as it ships. Our first priority is a **rock-solid analytics core** - events, profiles, insights, and dashboards you can trust. The bigger bets below build outward from that foundation. ## Available today Pug is a product analytics platform - capture events, identify the people behind them, and explore behavior through trends, funnels, retention, and segmentation, all on customizable dashboards. Client and server SDKs, plus a language-agnostic HTTP API: | Platform | Package | Docs | |----------|---------|------| | Web | `@pug-sh/browser` | [Web SDK](/sdks?platform=web) | | Flutter | `pug_flutter` | [Flutter SDK](/sdks?platform=flutter) | | Node.js (server) | `@pug-sh/node` | [Node SDK](/sdks?platform=node) | | HTTP API | - | [API reference](/api) | You can also [self-host](/self-hosting) the entire stack today, and capture is [cookieless by default](/sdks#tracking-consent) - no device identifier is stored until the reader consents. ## Privacy & compliance Pug is privacy-first by design - [tracking-consent gating](/sdks#tracking-consent), no visitor-IP persistence, and GDPR/DPDP data-subject erasure are already in the product. **Cookieless capture has shipped, and it's the default.** The Web SDK's `trackingConsent` starts at `'cookieless'`: events flow from the first hit, but nothing is written to the device until the reader answers your banner. Global Privacy Control (`respectGpc`) and a bounded identifier lifetime (`maxAgeDays`, default 365) landed alongside it. See [Tracking consent](/sdks#tracking-consent). One area takes it further. ### DPDP hardening Deeper compliance controls for India's **Digital Personal Data Protection Act**: consent records and withdrawal, data-principal rights (access, correction, grievance redressal), retention limits, and breach notification, building on the GDPR/DPDP data-subject erasure, export, and retention the compliance worker already runs. ## More SDKs Native and server-side libraries are in active development, built on the same event and identity model as today's SDKs. **Until each lands, the [HTTP API](/api) works from any language or runtime.** ### Client SDKs | Platform | What | |----------|------| | Android | Native Kotlin SDK with provider-neutral push | | iOS | Native Swift SDK for tracking and push | | React Native | Cross-platform tracking for React Native apps | ### Server SDKs Idiomatic server-side libraries that send events and read analytics with your private key, the way the [Node SDK](/sdks?platform=node) does today. **Go**, **Rust**, **Java**, **Python**, and **Ruby** are in progress. ## Customer engagement Once the analytics core is stable, the next chapter is **acting on** your data, not just measuring it. Run **multi-channel campaigns composed from the same profiles and events you already track** - one composer, delivered to the channels your users are on. **Planned delivery channels:** | Group | Channels | |-------|----------| | Push | Web Push, Android Push, iOS Push | | Messaging | Email, SMS, WhatsApp | | Programmatic | Webhook | The foundations are already shipping inside the analytics SDKs - so when the campaign composer lands, your events and devices are already wired up: ## Shape the roadmap Building something and need a platform sooner, or want a feature that isn't here? Tell us - it genuinely moves priorities: - **GitHub**: open or upvote a discussion at [github.com/pug-sh/pug](https://github.com/pug-sh/pug). - **Discord**: [join the community](https://discord.gg/kDNHDWcBHP). - **Reddit**: [r/pug_sh](https://www.reddit.com/r/pug_sh/). # SDKs > Client and server libraries for sending events and managing identity - Web, Flutter, Node, and the raw HTTP API. Source: https://docs.pug.sh/sdks/ | Markdown: https://docs.pug.sh/sdks/index.md Client and server libraries for sending events and managing identity from your apps. Every integration shares one mental model (**initialize once, track events, identify users, reset on sign-out**) whether you use a browser/app SDK, the server-side **Node** SDK, or call the API directly. The one exception is *reset*: the Node SDK has no ambient user or session to reset, so you name the `distinctId` on each call instead. - `init(projectId, options)` - once at app startup - `track(name, props)` - throughout the app - `identify(id, traits)` - after sign-in - `reset()` - on sign-out (client SDKs) ## Choose your platform | Platform | Use it when | Package | |----------|-------------|---------| | **Web** | Browser apps: React, Vue, Next.js, or plain JavaScript | `@pug-sh/browser` | | **Flutter** | Cross-platform mobile & desktop apps | `pug_flutter` | | **Node** | A Node.js backend that sends events **and** reads analytics | `@pug-sh/node` | | **HTTP API** | Any other backend or language (anything that can POST JSON) | - | Native iOS, Android, and React Native SDKs are on the [roadmap](/roadmap) - for now, use **Flutter** for cross-platform mobile, or the **HTTP API** from any runtime. **Client vs server keys.** The **Web** and **Flutter** SDKs run in untrusted environments and authenticate with your **public key** (`pub_...`) - write-scoped (events + identify) and safe to ship in client code. The **Node** SDK runs on your server and uses your **private key** (`prv_...`), which additionally unlocks analytics **reads** (profiles, activity, insights); never embed it in client code. See [Authentication](/get-started/authentication). ## Install ### Web ```bash npm install @pug-sh/browser # or: pnpm add @pug-sh/browser | yarn add @pug-sh/browser | bun add @pug-sh/browser ``` [View `@pug-sh/browser` on npm](https://www.npmjs.com/package/@pug-sh/browser) The Web SDK ships two ways: as compiled JavaScript plus type definitions for **bundlers** (Vite, webpack, esbuild, Next.js, ...), or as a standalone **CDN bundle** you drop into a script tag with no build step. Both run in the browser and expose the same API - the bundler path is below; for the CDN, add it via a [loader snippet](#loader-snippet) or a [one-tag install](#one-tag-install). **With a bundler**, import named exports from the package root: ```ts import { init, track, identify, reset } from '@pug-sh/browser' ``` `init()` runs once wherever your app boots - there are no framework-specific packages. For Vite, Vue, and other bundlers, just call it at your entry point. #### Next.js App Router In the App Router, `init` must run in the browser, not during SSR, so call it from a client provider: ```tsx title="components/PugProvider.tsx" 'use client' import { init } from '@pug-sh/browser' import { useEffect } from 'react' export function PugProvider({ children }: { children: React.ReactNode }) { useEffect(() => { init(process.env.NEXT_PUBLIC_PUG_PROJECT_ID!, { apiKey: process.env.NEXT_PUBLIC_PUG_PUBLIC_KEY! }) }, []) return <>{children} } ``` The App Router also doesn't do a full History push on client-side navigation, so auto-tracking fires `page_view` only on the initial load - track later navigations explicitly with `track('page_view')` in a `usePathname()` effect (see [Auto-tracking](#auto-tracking)). #### Loader snippet **Via CDN (no bundler).** Both options below load one self-contained file from Pug's first-party CDN (`cdn.pugs.dev`) that installs the full API on `window.pug` - every function on this page becomes a method (`pug.track(...)`, `pug.identify(...)`, ...). The version is **pinned** in the URL path (`/v0.1.0/`); the file is immutable and edge-cached, so bump it when you upgrade - and roll every page over together, since [v0.1.0](#web-sdk-v010) changed the stored identity format and pages on two versions can't read each other's. Use **one** of these, not both. Paste this into ``. It defines `window.pug` right away and loads the bundle asynchronously; calls made before the bundle arrives (`init`, `track`, consent changes) are queued and replayed **in order**, so nothing is lost on a slow connection. Always call `pug.init()` first - the SDK drops calls queued ahead of it. ```html ``` Calls queued before load return `undefined` rather than their real value, so gate any read or `await` on the CDN-only `pug.ready(cb)` - it fires at its queued position during replay, or synchronously once the SDK is already live: ```html ``` #### One-tag install A single self-initializing tag with no inline code - for pages under a strict CSP that forbids inline scripts. The SDK auto-inits from the tag's `data-*` attributes: `data-project-id` and `data-api-key` are required, `data-endpoint` sets a self-hosted origin, and `data-options` is a JSON object of any other [init option](#initialize) (`trackingConsent`, `autoCapture`, ...). ```html ``` `data-options` is JSON, so it holds no functions - `beforeSend` is unavailable on this path, which makes the JSON-configurable [`redactUrlParams`](#redacting-pii) the only URL masking a one-tag page gets. It is on by default. There's also no pre-load queue: `window.pug` exists only after the bundle loads, so call `pug.*` from code that runs later (or use the loader snippet above if you need to queue calls immediately). Nothing here is type-checked, so every field is validated at runtime and **fails closed**: a missing `data-project-id`/`data-api-key` or unparseable `data-options` aborts the auto-init entirely, and an unrecognized `trackingConsent` value resolves to `'denied'` rather than to the permissive reading. Verify by opening your dashboard's **Live** view - an auto-tracked `page_view` appears within ~15 seconds. Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in [Install with AI](/get-started/ai-setup). ```text Add Pug product analytics to this app. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - SDK reference - https://docs.pug.sh/get-started/index.md - quickstart - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the product actually does: its routes or screens, its main models, and the handful of things a user comes here to complete. The events worth tracking are the moments the product exists to produce - an account created, an order placed, a document shared - not every button on the page. If you cannot state the core action in one sentence, keep reading. Then: 1. Install @pug-sh/browser. 2. Call init() exactly once, where the app boots, in the browser only - never during SSR. In the Next.js App Router that means a "use client" provider mounted from the root layout. 3. Read the project ID and public key from environment variables. The public key starts with pub_ and is safe to ship. A private key starts with prv_ and must never reach client code. 4. Leave auto-tracking on and do not hand-roll page views - init() already captures them. 5. Add track() calls for the three or four actions you identified - the ones someone would look at in a weekly review. Property values must be plain JSON scalars: strings, numbers, booleans, dates. 6. If this app has authentication, call identify() after sign-in and reset() on sign-out. Note that the SDK is cookieless until optInTracking() is called, so identify() is a no-op without granted consent - tell me whether I need a consent banner rather than opting users in silently. Name events from the catalog before you invent one. It holds 127 well-known events across 19 families - commerce, authentication, billing, media, forms, support, workspace and more - and a well-known name is type-checked as you write it, validated at ingestion, and fills in dashboards that a custom name leaves empty. Search it for every action you are about to track, and take property names and types from that page rather than from whatever reads idiomatically. Fall back to a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this product does, then list the files you changed, the event names you chose, and the environment variables I need to set. ``` ### Flutter ```bash flutter pub add pug_flutter ``` [View `pug_flutter` on pub.dev](https://pub.dev/packages/pug_flutter) `flutter pub add pug_flutter` adds it to `pubspec.yaml` and fetches it; to pin the version yourself, add it under `dependencies` instead: ```yaml title="pubspec.yaml" dependencies: pug_flutter: ^0.0.4 ``` | Requirement | Minimum | |-------------|---------| | Dart SDK | `>=3.7.0 <4.0.0` | | Flutter | `>=3.29.0` | Import it wherever you call the SDK: ```dart import 'package:pug_flutter/pug_flutter.dart'; ``` Smoke-test by adding a minimal `Pug.init` in `main()` (see **Initialize**) and running the app - with auto-tracking on, an `app_open` event appears in **Live** within a few seconds. (`page_view` events need the `PugRouteObserver` - see [Auto-tracking](#auto-tracking).) Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in [Install with AI](/get-started/ai-setup). ```text Add Pug product analytics to this Flutter app. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - SDK reference (switch to the Flutter panes) - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the app actually does: its screens and navigation graph, its main models, and the handful of things a user opens the app to complete. The events worth tracking are the moments the app exists to produce - an account created, an order placed, a workout finished - not every tap. If you cannot state the core action in one sentence, keep reading. Then: 1. Add pug_flutter to pubspec.yaml. 2. Await Pug.init() in main(), after WidgetsFlutterBinding.ensureInitialized() and before runApp(). 3. Keep the project ID and public key (pub_...) out of source - pass them with --dart-define and read them with String.fromEnvironment. 4. Register PugRouteObserver on the app's navigatorObservers, otherwise screen views never fire. 5. Add tracking for the three or four actions you identified - the ones someone would look at in a weekly review. 6. If this app has authentication, call Pug.identify() after sign-in and Pug.reset() on sign-out. Name events from the catalog before you invent one. Flutter gets a generated named-argument method for all 127 well-known events - Pug.track.purchase(productId: ..., amount: ...) - so the compiler checks the properties for you, and the dashboards those events drive stay empty for a custom name. Reach for Pug.track. first, search the catalog for every action you are about to track, and pass schema-external properties through extras. Use Pug.track(kind, props: {...}) with a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not. Flutter consent has two states and starts granted, which is not the same as the Web SDK - check the consent section before you add a banner. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this app does, then list the files you changed, the event names you chose, and the dart-defines I need to pass. ``` ### Node ```bash npm install @pug-sh/node # or: pnpm add @pug-sh/node | yarn add @pug-sh/node | bun add @pug-sh/node ``` [View `@pug-sh/node` on npm](https://www.npmjs.com/package/@pug-sh/node) The Node SDK is server-side and ships as ESM - it needs **Node.js 18+**. Import the `Pug` class: ```ts import { Pug, PugError } from '@pug-sh/node' ``` Unlike the client SDKs it authenticates with your **private key** (`prv_...`), so keep it on the server - in an environment variable, never in code shipped to a browser or app. Construct one instance at boot and reuse it across requests. Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in [Install with AI](/get-started/ai-setup). ```text Add Pug product analytics to this Node backend. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - SDK reference (switch to the Node panes) - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the service actually does: its routes or handlers, its main models, and the state changes that matter to the business. On a server the events worth tracking are the ones a browser cannot observe - a payment settling, a webhook arriving, a job finishing, a subscription lapsing - not request logging. If you cannot state what this service is for in one sentence, keep reading. Then: 1. Install @pug-sh/node. It is ESM and needs Node 18+. 2. Construct one Pug instance at boot and reuse it across requests - do not build one per request. Its apiKey is the private key (prv_...), read from an environment variable, and it must never be imported into client-side code. 3. Remember the argument order: pug.track(distinctId, kind, props?, options?). The distinctId comes first because a server has no ambient user - use our own user ID, or the anon-... ID forwarded from the client. 4. Add track calls for the three or four state changes you identified. 5. Call identify() when a user record is created or changes materially, not on every request. 6. Await pug.close() in the graceful-shutdown path so buffered events flush, and wire an onError handler so dropped events are logged rather than lost. Name events from the catalog before you invent one. The server SDK type-checks 114 of the 127 well-known events, and the ones a backend is placed to see are exactly the ones whose schemas are worth having - purchase, subscription_started, invoice_paid, payment_failed, order_refunded. Search the catalog for every action you are about to track, take property names and types from that page, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not. There is no reset() or destroy() on the server SDK, and no immediate option on track - use flush(). Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this service does, then list the files you changed, the event names you chose, and the environment variables I need to set. ``` ### HTTP API Nothing to install - send requests straight to the API at `https://api.pugs.dev`, or your self-hosted base URL (e.g. `http://localhost:3000`), with three headers: | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Connect-Protocol-Version` | `1` | | `x-api-key` | Your **public key** (`pub_...`) | The public key is write-scoped and safe in server-side code that sends events on behalf of your users. Never use a private key for ingestion. Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in [Install with AI](/get-started/ai-setup). ```text Add Pug product analytics to this service by calling the HTTP API directly - there is no SDK for this runtime. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/index.md - the HTTP panes, including worked request examples - https://docs.pug.sh/api/events/index.md - the full event schema - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the service actually does: its routes or handlers, its main models, and the state changes that matter to the business. The events worth tracking are the moments the service exists to produce, not request logging. If you cannot state what this service is for in one sentence, keep reading. Then: 1. Write one small client that POSTs to /sdk.events.v1.EventsService/BatchCreate on the base URL, with the headers Content-Type: application/json, Connect-Protocol-Version: 1, and x-api-key set to the public key (pub_...) from an environment variable. 2. Wrap every custom property value in its typed form - stringValue, intValue (a JSON string), doubleValue, boolValue, timestampValue. This is the part that is most often wrong. Property keys must not start with a dollar sign, and the event kind must not start with pug. 3. Send events in batches of at most 1000, and read "dropped" and "droppedByReason" on the response - a 200 does not mean every event landed. 4. Call it for the three or four actions you identified. Name events from the catalog before you invent one. All 127 well-known events are available over HTTP, and since nothing here is compile-checked they are the only validation you get: a well-known kind has its properties checked at ingestion and fills in the dashboards built on it, while a custom kind is accepted unvalidated and silently. Search the catalog for every action you are about to track, take property names and types from that page, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not. Do not invent fields that are not in the events schema above. When you are done, tell me in one sentence what you concluded this service does, then show me the client, the event names you chose, and the environment variables I need to set. ``` ## Initialize Client SDKs call `init` **once** at startup with your project ID and public key; the **Node** SDK constructs a `Pug` instance with your private key instead. Either way, every other call needs it first. Find your project ID in the dashboard URL (`/p/`), your public key on your project **Overview**, and both kinds under **Settings > API Keys**. A private key is only shown when you [create it](/get-started/authentication) - if you don't have yours, issue a new one. > **Endpoint defaults to Pug Cloud** > > Every SDK defaults `endpoint` to `https://api.pugs.dev`, so you only set it when > **self-hosting**, pointing it at your own server (the dev server listens on port > `3000`). ### Web ```ts import { init } from '@pug-sh/browser' init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY' }) ``` `init` returns nothing. It **throws** if `projectId` or `apiKey` is missing, and no-ops (with a console warning) if called a second time or outside a browser. | Option | Type | Default | Description | |--------|------|---------|-------------| | `apiKey` | `string` | **required** | Public key (`pub_...`), sent as `x-api-key`. | | `endpoint` | `string` | Pug Cloud | API base URL. Defaults to `https://api.pugs.dev` - set it only when self-hosting. A non-`https` origin (other than localhost) warns at init. | | `batch` | `BatchOptions` | `maxSize 10`, `maxWaitMs 5000`, `maxQueueSize 1000` | Queue size and flush timing. | | `session` | `SessionConfig` | `idleTimeoutMinutes 30`, `maxSessionMinutes 1440` | Session idle and max-duration timeouts (minutes). | | `autoCapture` | `boolean \| AutoCaptureSelection` | `true` | Automatic capture: `true` enables all listeners, `false` none, an object is a per-listener allowlist. See [Auto-tracking](#auto-tracking). | | `trackingConsent` | `TrackingConsent \| TrackingConsentConfig` | `'cookieless'` | Consent gate. See [Tracking consent](#tracking-consent). | | `crossSubdomainTracking` | `boolean \| { domain }` | `false` | Share identity across subdomains via a first-party cookie. Off by default - it relaxes origin isolation to same-site, and it stays inert until consent is `'granted'`. See [Cross-subdomain identity](#cross-subdomain-identity). | | `maxAgeDays` | `number` | `365` | How long stored identifiers live. The deadline is absolute (stamped on first write, never extended), so a returning visitor still ages out on schedule. Pass `390` for CNIL's 13 months. | | `redactUrlParams` | `readonly string[] \| false` | built-in list | Query/fragment params whose values are replaced with `redacted` in `$url`, `$referrer` and form actions. See [Redacting PII](#redacting-pii). | | `beforeSend` | `BeforeSendFn` | - | Redact, rewrite or drop each event before it is sent. Fails closed. See [Redacting PII](#redacting-pii). | | `debug` | `boolean` | `false` | Log the SDK's internal activity to `console.debug` (DevTools' "Verbose" level). Turn it on when events aren't arriving. Warnings and errors are logged regardless, so this can only widen what you see. | | `dryRun` | `boolean` | `false` | Build events as normal but never send them. It does not change consent, or what `isTrackingEnabled()` reports. | Tear down and re-init with `destroy()` (flushes the queue, removes listeners). `reset()` and `rotate()` are covered under [Identify and sessions](#identify-and-sessions). > **sanitizeUrl was removed in v0.1.0** > > Earlier versions took a `sanitizeUrl` callback. It is gone - TypeScript rejects > it, and a JavaScript or one-tag install logs a warning and ignores it, so the > masking it used to do silently stops. Known-sensitive query and fragment params > are still redacted by default (`redactUrlParams`); anything further belongs in > `beforeSend`. See [Upgrading](#web-sdk-v010) for the migration. ### Flutter ```dart import 'package:pug_flutter/pug_flutter.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await Pug.init( 'YOUR_PROJECT_ID', const PugOptions( apiKey: 'pub_your_public_key', ), ); runApp(const MyApp()); } ``` `Pug.init` returns `Future` and **throws `ArgumentError`** if `projectId` or `apiKey` is empty. A second `init` while running is silently ignored (a warning is logged via `PugLogger`); call `Pug.destroy()` first to reinitialize. | Option | Type | Default | Description | |--------|------|---------|-------------| | `apiKey` | `String` | **required** | Public key (`pub_...`), sent as `x-api-key`. | | `endpoint` | `String` | `https://api.pugs.dev` | API base URL: set it only when self-hosting. | | `batch` | `BatchConfig` | `maxSize 10`, `maxWaitMs 5000`, `maxQueueSize 1000` | Queue size and flush timing; persisted to `SharedPreferences`. | | `session` | `SessionConfig` | `idleTimeout 30 min`, `maxDuration 24 h` | Session timeouts as `Duration`s. | | `autoTrack` | `bool` | `true` | Master switch for all auto-trackers (lifecycle + page views). | | `autoPageViews` | `bool` | `true` | Emit a navigation event on route changes: `screen_view` on mobile, `page_view` on web (needs `PugRouteObserver`). | | `autoCaptureCampaigns` | `bool` | `true` | Capture UTM parameters and ad click-ids from incoming deep links as auto-properties. | | `trackingConsent` | `TrackingConsentConfig` | `const TrackingConsentConfig()` | Consent gate: **two states, granted by default**, unlike the Web SDK. See [Flutter consent](#flutter-consent). | | `dryRun` | `bool` | `false` | Log via `PugLogger` without sending. | Advanced overrides (`logger`, `storage`, `transport`, `autoPropertyProvider`, `linkProvider`) let you inject test doubles or custom collection. ### Node ```ts import { Pug } from '@pug-sh/node' // Instance-based - construct once at boot, reuse across requests. const pug = new Pug({ apiKey: process.env.PUG_PRIVATE_KEY!, // prv_... - server-side only, never ship to a client }) ``` `new Pug(options)` returns an instance immediately. The constructor **throws** if `apiKey` is missing or doesn't start with `prv_` - the server SDK refuses a public key up front rather than failing later on the first read. | Option | Type | Default | Description | |--------|------|---------|-------------| | `apiKey` | `string` | **required** | Private key (`prv_...`), sent as `x-api-key`. Server-side only. | | `endpoint` | `string` | Pug Cloud | Pug server origin. Defaults to `https://api.pugs.dev` - set it only when self-hosting. | | `batch` | `Partial` | `maxSize 100`, `maxWaitMs 5000`, `maxQueueSize 10000` | Ingestion buffer size and flush timing. | | `onError` | `OnError` | no-op | Dead-letter hook for buffered `track()` events that couldn't be delivered - permanent send failure, queue overflow, or still-undelivered at `close()`. (`identify()` failures are logged, not routed here.) | Call `await pug.close()` on graceful shutdown so buffered events flush - anything still undeliverable is reported through `onError`. The Node SDK has no `destroy()`, `reset()`, or `rotate()`: a server has no ambient user or session to reset. ### HTTP API There is no initialization step. Authenticate every request with the `x-api-key` header (your `pub_...` key) and target your base URL - `https://api.pugs.dev`, or a self-hosted URL such as `http://localhost:3000`. Event properties are wrapped by hand (see [Track events](#track-events)). ## Track events Events are the foundation of every analytic in Pug. The first argument is the event **name** (the `kind`): custom names work without a schema, while [well-known events](/reference/well-known-events) add typed properties and richer dashboards. Names starting with `pug.` are reserved. ### Web ```ts import { track } from '@pug-sh/browser' // Custom event - any name track('button_clicked', { label: 'Sign up', page: '/pricing' }) // Well-known event - typed properties track('purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' }) ``` `track()` **never throws** and returns nothing - events are queued and flushed in the background, so analytics can't break or block your app. **Property values** accept `string` (truncated to **1024 bytes** client-side), `number`, `boolean`, and `Date` (stored as a timestamp). Objects and arrays are JSON-encoded to a string. `null`, `undefined`, and non-finite numbers are dropped. Send numbers as numbers - aggregations like Sum don't work on strings. `track(event, props?, options?)` options: | Option | Type | Description | |--------|------|-------------| | `immediate` | `boolean` | Bypass the batch queue and send right away. | | `timestamp` | `number` | Override the event time, as **epoch milliseconds** (e.g. `Date.now()`). | ```ts // Conversion that must arrive before a redirect track('purchase', { productId: 'sku_789', amount: 99, currency: 'USD' }, { immediate: true }) ``` ### Flutter ```dart // Custom event - any name Pug.track('button_clicked', props: {'label': 'Sign up', 'screen': 'pricing'}); // Typed well-known event - compile-time checked Pug.track.purchase(productId: 'sku-1', amount: 99.50, currency: 'USD'); ``` `Pug.track(...)` **never throws** - calls are wrapped in try/catch and queued in the background. The generated `Pug.track.*` namespace has a typed method per well-known event; pass schema-external properties via `extras: {...}` (on a key collision, the named argument wins). For runtime-determined names, call `Pug.track(kind, props: {...})` directly. **Property values** accept `String` (truncated to 1024 UTF-8 bytes), `bool`, `int`, finite `double`/`num` (non-finite dropped), and `DateTime` (UTC epoch ms). `Iterable`/`Map` are JSON-encoded to a string; `null` is dropped - omit the key instead. `TrackOptions` (on `Pug.track` and every typed method): | Option | Type | Default | Description | |--------|------|---------|-------------| | `immediate` | `bool` | `false` | Bypass the queue and send now (falls back to the queue on transient failure). | | `timestampMillis` | `int?` | wall clock | Override the event time as **epoch milliseconds**. | ```dart Pug.track.purchase( productId: 'sku-789', amount: 99.0, currency: 'USD', options: const TrackOptions(immediate: true), ); ``` ### Node ```ts // distinctId (who the event is for) comes first - a server has no ambient user. pug.track('user-123', 'order.completed', { amount: 49, currency: 'USD' }) // Well-known events get typed, validated properties. pug.track('user-123', 'purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' }) ``` `pug.track(distinctId, kind, props?, options?)` puts the **`distinctId` first** - a server has no ambient user, so you name who each event is for (your external ID, or an `anon-...` ID forwarded from the client). It **never throws** and is non-blocking: events are validated, batched, and flushed in the background. There is no `{ immediate }` on the server - flush explicitly instead: ```ts await pug.flush() // send what's buffered right now await pug.close() // drain on graceful shutdown so nothing buffered is lost ``` `flush()` resolves when that batch attempt settles - not a guarantee the queue is empty, since a transient failure re-queues for a later flush. Use `close()` to drain fully on shutdown. `track` options: `timestamp` (epoch-millisecond override) and `sessionId` (per-call session override - otherwise the instance uses one session ID for its lifetime). **Property values** accept the same JSON scalar types as the other SDKs. ### HTTP API **Endpoint:** `POST /sdk.events.v1.EventsService/BatchCreate` (max **1000 events** per request). ```json { "events": [ { "eventId": "01966b9e-1234-7abc-abcd-0123456789ab", "distinctId": "anon-abc123", "kind": "purchase", "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "occurTime": "2026-06-05T10:00:00Z", "customProperties": { "productId": { "stringValue": "sku_123" }, "amount": { "doubleValue": 29.99 }, "currency": { "stringValue": "USD" } }, "autoProperties": {} } ] } ``` The SDKs wrap property values for you; over raw HTTP you wrap each value in a typed object yourself - the single most important thing to get right: | Type | JSON shape | Notes | |------|-----------|-------| | String | `{ "stringValue": "hello" }` | SDKs truncate strings to 1024 bytes | | Integer | `{ "intValue": "42" }` | int64 - value is a **JSON string** | | Double | `{ "doubleValue": 3.14 }` | must be finite | | Boolean | `{ "boolValue": true }` | | | Timestamp | `{ "timestampValue": "2026-06-05T10:00:00Z" }` | RFC 3339 | `customProperties` keys must **not** start with `$` (reserved for auto-properties); `kind` must not start with `pug.`. To send events for a visitor who hasn't consented, omit `distinctId` and `sessionId` and set `"cookieless": true` - the server derives both. The response is `{ "accepted": }`, plus `dropped` and `droppedByReason` when part of a batch was refused, so check `dropped` rather than reading a 200 as "all landed". See [Errors and the raw HTTP API](#errors-and-the-raw-http-api) for status codes and full examples, and the [Events API reference](/api/events) for the complete schema. ## Identify and sessions Identity turns anonymous events into actionable profiles. The pattern: events accumulate against an anonymous ID, you `identify()` on sign-in to merge that history into a known profile, and `reset()` on sign-out. Use a **stable, unique** external ID - your database user ID, not an email (which can change). Only the **first** identify after an anonymous session merges the prior activity; the same external ID always resolves to the same profile, and traits are merged (never removed) server-side. > **traits is plain JSON, not PropertyValue-wrapped** > > Unlike event `customProperties` (which need `{"plan":{"stringValue":"pro"}}`), > identify `traits` is a `google.protobuf.Struct` - pass bare JSON values > (`"plan": "pro"`, `"seats": 5`). The SDKs send traits as-is; only event > properties get the wrapper. ### Web ```ts // On sign-in - merge anonymous history into the known profile await identify('user-123', { email: 'user@example.com', plan: 'pro' }) // On sign-out - clear identity, start a fresh anonymous session reset() // New session ID without clearing identity rotate() ``` `identify(externalId, traits?)` returns a **promise** and **never throws** - invalid input, a call before `init()`, denied consent, cookieless mode, `dryRun`, and RPC failures are all logged and the promise resolves without sending, so you can `await` it without a try/catch. On the first identify it includes the anonymous ID so prior anonymous events merge into the profile. The `externalId` must not start with `cookieless-`: the server reserves that prefix for the identities it derives itself and rejects any batch carrying one. `reset()` clears the stored identity and starts a new anonymous session - it does **not** delete the profile in the dashboard. It sends any queued events once before dropping them, since they were collected while that user was signed in, and **returns `false`** when something could not be removed. On a shared machine that is worth surfacing: with [cross-subdomain identity](#cross-subdomain-identity) it means the cookie survived on the registrable domain and the next person inherits it. ```ts if (!reset()) { showSignOutWarning() } ``` | Function | Clears identity | New device ID | New session ID | |----------|-----------------|---------------|----------------| | `rotate()` | No | No | Yes | | `reset()` | Yes | Yes | Yes | > **Identify needs a stored identifier** > > Under the default `'cookieless'` consent state the SDK writes no identifier to > the device, so there is no anonymous ID to merge and `identify()` is a no-op > (warned once per page load). Call it once the reader has granted consent - see > [Tracking consent](#tracking-consent). To branch on that in code, check > `getTrackingConsent() === 'granted'` rather than `isTrackingEnabled()`, which is > also `true` in cookieless mode. ### Flutter ```dart // On sign-in await Pug.identify('user-123', traits: {'email': 'user@example.com', 'plan': 'pro'}); // On sign-out Pug.reset(); // New session ID, identity kept Pug.rotate(); ``` `Pug.identify(externalId, {traits})` returns `Future` and **never throws** - invalid input and transport failures are logged and the future completes normally; a call before `Pug.init()` is ignored with a warning. `reset()` is synchronous - it generates a new device ID, a new anonymous ID, and a new session, and removes the stored `externalId` (the profile stays in the dashboard). Drain the queue with `await Pug.flush()`; fully tear down with `Pug.destroy()`. | Function | Clears identity | New device ID | New session ID | |----------|-----------------|---------------|----------------| | `rotate()` | No | No | Yes | | `reset()` | Yes | Yes | Yes | ### Node ```ts // On the first identify, pass the SDK's anonymousId to merge prior anonymous activity. await pug.identify('user-123', { email: 'user@example.com', plan: 'pro' }, { anonymousId: 'anon-abc123', }) ``` `pug.identify(externalId, traits?, options?)` returns a promise and **never throws** - failures are logged. Pass `{ anonymousId }` (must start with `anon-`) on the first identify to merge a client's prior anonymous activity into the profile, and `{ deviceId }` to attach a device. There is no `reset()` or `rotate()`: identity isn't ambient on the server - you name the `distinctId` on every `track()` call instead. ### HTTP API **Endpoint:** `POST /sdk.profiles.v1.ProfilesSDKService/Identify` ```json { "externalId": "user-123", "traits": { "email": "user@example.com", "plan": "pro", "seats": 5 }, "anonymousId": "anon-abc123" } ``` | Field | JSON name | Required | Description | |-------|-----------|----------|-------------| | `external_id` | `externalId` | Yes | Stable user identifier | | `traits` | `traits` | No | Profile properties; shallow-merged on conflict | | `anonymous_id` | `anonymousId` | No | Anon ID to merge; must start with `anon-`. Send on the first identify | | `device_id` | `deviceId` | No | Device to assign (mobile SDKs; omit for web) | The response is an empty object `{}` on success. You set each event's `distinctId` yourself - the anonymous ID before identify, your external ID after. ### Sessions Sessions group events within a single visit. The SDK creates a session ID on the first event, extends it on each event within the idle window, and starts a new one once the idle or max-duration timeout passes - identity is preserved across the boundary. The **Web** SDK syncs the session across tabs via `localStorage`; **Flutter** persists it to `SharedPreferences` and restores it on cold start (re-checking the timeouts). The **Node** SDK uses a single session ID for the instance's lifetime - override it per call with `track(..., { sessionId })`, since sessions are a client concept. Configure the session timeouts at init on the **Web** (`idleTimeoutMinutes`/`maxSessionMinutes`) and **Flutter** (`idleTimeout`/`maxDuration`) SDKs - see [Initialize](#initialize). For the anonymous->identified model, see [Core concepts](/get-started/concepts). > **Cookieless sessions are stitched server-side** > > Under the default `'cookieless'` consent state the Web SDK stores no session and > sends no `sessionId` - the server derives one instead, from the same > daily-rotating anonymous ID, and closes it after 30 minutes of inactivity. Events > still group into visits; the client-side `session` timeouts just have nothing to > configure until consent is granted. ### Cross-subdomain identity **Web only.** By default the Web SDK stores identity in origin-scoped `localStorage`, so `www.example.com` and `app.example.com` are two unrelated visitors and a signup can't be traced back to the marketing page that produced it. `crossSubdomainTracking` moves that storage to a first-party cookie on the registrable domain, and the two become one profile with one funnel. ```ts init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY', crossSubdomainTracking: true, // auto-discover the registrable domain // crossSubdomainTracking: { domain: 'example.com' }, // or pin it explicitly }) ``` It shares the anonymous ID, an `identify()`ed external ID, session state, and the persisted consent choice - so an opt-out on one subdomain applies to its siblings too. **Both ends must be on the same project**: the cookie key is project-scoped, so a mismatch silently splits every visitor in two rather than erroring. > **It does nothing until consent is granted** > > [Cookieless](#tracking-consent), the default state, writes **no** identifier to > the device, cookie included. So an install that turns this option on and changes > nothing else shares nothing, and the subdomains stay separate until the reader > opts in. That order is deliberate (nothing is stored before there's a basis for > it), but it is the most common surprise here: if sharing isn't happening, check > `getTrackingConsent()` before you check your domain. One pattern for a marketing site and an app that share a domain, without a banner on either: the **app** grants consent at sign-in, since the account is the basis for it, and drops back to cookieless on sign-out. The **marketing site** grants only when the shared cookie is already there (a device that has signed in already) and stays cookieless for everyone else. Anonymous readers are still counted, through the server's own daily-rotating cookieless identity, and a returning customer's visit lands on the profile it belongs to. What that model gives up is the pre-signup half of the funnel: a cookieless visitor has no anonymous ID to merge, so `identify()` links nothing retroactively. If you need that half, the join has to be a value you know on both sides - an email captured at signup and passed to `identify()` on each property. Four things to know before switching it on: - **It relaxes browser isolation** from same-origin to same-site, which is why it is off by default rather than on. - **On a multi-tenant domain not on the [Public Suffix List](https://publicsuffix.org/)** (`a.myplatform.com` and `b.myplatform.com` as separate customers), auto-discovery resolves to the shared `myplatform.com`, letting sibling tenants read each other's identity. Pin an explicit `{ domain }` there. - **It degrades rather than failing**: a host-only cookie on `localhost` and IP hosts, and `localStorage` when cookies are blocked. Cookies set over HTTPS carry `Secure`, so identity is shared only among HTTPS subdomains. - **Sessions end on timeout only.** The "rotate the session once every tab is closed" heuristic is origin-scoped and is disabled in this mode. ## Auto-tracking With automatic capture on (the default), the client SDKs capture common interactions for you - no manual `track()` calls. Every auto-tracked event also carries the standard [auto-properties](/reference/auto-properties), and the **server** attaches enrichment properties to *every* event regardless of platform. > **Auto-property keys are camelCase** > > On the client SDKs: `$utmSource`, `$pageTitle`, `$screenWidth`. The only > snake_case keys are the two server-set bot signals, `$bot_score` and > `$verified_bot`. See [Auto-properties](/reference/auto-properties) for the full > per-platform catalog. ### Web Six trackers install with `autoCapture` on (the default). Pass a per-listener allowlist to run only some (omitted keys stay off) and change the selection at runtime with `setAutoCapture()`: ```ts import { init } from '@pug-sh/browser' init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY', autoCapture: { pageView: true, click: true } }) ``` It is an **allowlist**, not a set of switches: the object above enables page views and clicks, and scroll, form, rage clicks and dead clicks all stay off. There is no way to spell "everything except X" - setting a key to `false` never turns another one on, so `{ scroll: false }` enables nothing at all. The values are typed `true` for that reason and TypeScript rejects an explicit `false`; for a flag known only at runtime, write `|| undefined` so the key is omitted rather than set: ```ts autoCapture: { pageView: true, scroll: enableScroll || undefined } ``` Plain JavaScript and the [one-tag install](#one-tag-install) aren't type-checked, so the SDK also warns whenever a selection ends up enabling nothing (from an explicit `false`, a misspelled key, or a stringy `"true"` out of a config store) and names what it actually enabled. | Tracker | Allowlist key | Event(s) | Captured properties | |---------|---------------|----------|---------------------| | Page views | `pageView` | `page_view` | auto-properties only (`$url`, `$referrer`, ...) | | Clicks | `click` | `click` | `tag`, `id`, `class`, `text` (the element's [own text](#redacting-pii), max 50 chars), `x`, `y` | | Scroll | `scroll` | `scroll` | `percent` (0-100), `scrollY` | | Forms | `form` | `form_start`, `form_submit` | form **metadata** only - `formId`, `formName` (both), plus `action` on `form_submit`. Never field values | | Rage clicks | `rageClick` | `rage_click` | `clickCount`, `element`, `x`, `y` | | Dead clicks | `deadClick` | `dead_click` | `element`, `text` (the element's [own text](#redacting-pii), max 20 chars), `x`, `y` | `page_view` fires on the initial load and on SPA navigation via the History API (`pushState`/`replaceState`/`popstate`), so call `init()` **before** your router mounts. The Next.js App Router navigates without a History push - track those `page_view`s manually. To gate all capture behind a consent banner, see [Tracking consent](#tracking-consent). `$pageTitle` rides `page_view` only, not every event. Titles routinely carry names and order numbers, and a title is still joinable to the events around it through `sessionId`. ### Flutter **Navigation** is captured when you wire a `PugRouteObserver` into your `MaterialApp`: ```dart MaterialApp( navigatorObservers: [PugRouteObserver()], // ... rest of your config ) ``` On each route change the SDK emits a navigation event: `screen_view` (with `screenName` set to the route name) on **iOS/Android**, and `page_view` on **web**; desktop targets emit neither. The navigation event itself carries no url props - instead every event picks up the current and previous route as the `$url` and `$referrer` [auto-properties](/reference/auto-properties), so route context is present even when `autoPageViews` is off. Route names come from `route.settings.name`, falling back to the route's runtime type. **App lifecycle** is observed automatically (no wiring): `app_open` when the app enters the foreground, and `app_close` on background/hidden/paused/detached - sent with `immediate: true` so it arrives before the OS kills the process. **Campaign capture** (`autoCaptureCampaigns`, on by default) reads UTM parameters and ad click-ids from incoming deep links and attaches them as auto-properties (`$utmSource`, `$utmMedium`, `$gclid`, ...) to subsequent events. ### Node There is no client-side auto-tracking on the server - you send every event explicitly with `pug.track(distinctId, ...)`. The **server** still enriches each event during processing, attaching geo (`$country`, `$region`, `$city`) and bot-detection (`$bot_score`, `$verified_bot`) auto-properties you don't set yourself. ### HTTP API There is no client-side auto-tracking over raw HTTP - you send every event explicitly. The **server** still enriches each event during processing, attaching geo (`$country`, `$region`, `$city`) and bot-detection (`$bot_score`, `$verified_bot`) auto-properties you don't set yourself. ## Consent and privacy The Web SDK ships GDPR/CCPA controls that run **in the browser before anything is sent**, so raw values never leave the device. Flutter has a simpler two-state gate ([below](#flutter-consent)); over the Node SDK or raw HTTP, redact PII before you call `track()`. > **The two client SDKs default differently** > > **Web defaults to `'cookieless'`**: events flow, nothing is stored on the > device. **Flutter defaults to granted** and writes identity from the first > event. They are not interchangeable, so an app that ships both surfaces needs > the Flutter side configured explicitly if you want the Web behaviour. ### Tracking consent **Web SDK.** Consent has **three** states, not two: | State | Events sent | Identifier stored on the device | |-------|-------------|--------------------------------| | `'granted'` | Yes | Yes - anonymous ID, session, any `identify()`ed external ID | | `'cookieless'` | Yes | **No** | | `'denied'` | No | No | **`'cookieless'` is the default.** Traffic is counted from the first event, but nothing is written to the device until the reader actually answers your banner - so an install that never configures consent still doesn't store identifiers before it has a basis to. Pass `'granted'` to opt into full identity from the first event, or `'denied'` to capture nothing at all. ```ts import { init, optInTracking, optOutTracking, isConsentPending } from '@pug-sh/browser' init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY', trackingConsent: { initial: 'cookieless', // default - events flow, nothing is stored onReject: 'denied', // what optOutTracking() resolves to persist: true, // remember the choice across reloads respectGpc: true, // honor the browser's Global Privacy Control signal } }) // Show your banner only while the reader hasn't chosen. if (isConsentPending()) showCookieBanner() optInTracking() // accepted -> 'granted': attach listeners, start storing identity optOutTracking() // declined -> resolves to `onReject` ``` `TrackingConsentConfig`: | Field | Type | Default | Description | |-------|------|---------|-------------| | `initial` | `'granted' \| 'denied' \| 'cookieless'` | `'cookieless'` | First-run seed, used when nothing is persisted yet. Named `initial` rather than `default` because `default` is a reserved word. | | `onReject` | `'denied' \| 'cookieless'` | `'denied'` | What `optOutTracking()` resolves to. Set `'cookieless'` to keep identity-free traffic counts after a rejection. `'granted'` is deliberately not allowed. | | `persist` | `boolean` | `false` | Persist the choice and restore it on the next `init()`. Recommended for consent-first flows; without it, consent lives in memory and you re-seed `initial` on every load. | | `respectGpc` | `boolean` | `false` | Honor the browser's [Global Privacy Control](https://globalprivacycontrol.org/) signal, resolving it to `onReject`. Read once per `init()`; a choice made on your site outranks it. Legally binding under CCPA/CPRA. Pair it with `persist: true` - see [below](#global-privacy-control). | | Function | Returns | Description | |---|---|---| | `optInTracking()` | `boolean` | Grant consent, attach listeners (applying the stored `autoCapture` selection), allow `track()` / `identify()`. | | `optOutTracking()` | `boolean` | Revoke consent, resolving to `onReject`. Tears down listeners, drops future `track()` / `identify()`, and clears the stored anonymous ID / session. | | `setTrackingConsent(state)` | `boolean` | Set any of the three states explicitly. `setTrackingConsent('denied')` always means literally denied, regardless of `onReject`. | | `isTrackingEnabled()` | `boolean` | Whether events are flowing right now: `true` for both `'granted'` and `'cookieless'`. Independent of `dryRun`. | | `getTrackingConsent()` | `TrackingConsent \| undefined` | The state the SDK is acting on, or `undefined` before `init()` (a persisted choice is only read during `init()`). | | `isConsentPending()` | `boolean` | `true` while the reader hasn't chosen: gate your banner on this, not on `getTrackingConsent()`, which can't tell a seeded `'granted'` from a chosen one. | | `setAutoCapture(selection)` | `void` | Change the automatic-listener allowlist at runtime: applied immediately while tracking is active, deferred until opt-in when denied. | #### Cookieless mode In `'cookieless'` the SDK writes no session, no anonymous ID, no cross-subdomain cookie, not even the queued event payloads, and sends no identity. The **server** derives an anonymous ID instead, so consent-rejecting visitors still appear in traffic metrics while staying out of user counts. That ID is an HMAC-SHA256 over the project, the request's IP and its user agent, keyed by a salt that rotates daily and is deleted within 48 hours; IP and user agent are hash inputs only, never stored and never returned, so once the salt is gone nothing links an ID back to either. The events are otherwise unchanged - a cookieless event carries the same [auto-properties](/reference/auto-properties) as any other, and only the identity fields are dropped. `identify()` is disabled in this state, and granting consent later starts a **fresh** identity: pre-consent events are never linked to it retroactively. The one thing still written is the consent choice itself, and only under `persist: true` - a record of the reader's refusal, so it survives a reload. That is a strictly-necessary preference rather than analytics identity. Setting both `initial` and `onReject` to `'cookieless'` covers each end of the flow (before the reader answers, and after they decline), so the banner itself never has to know the state exists: ```ts init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY', trackingConsent: { initial: 'cookieless', onReject: 'cookieless', persist: true } }) ``` For a CMP that separates "reject analytics cookies" from "reject everything", drive the three states directly instead - `onReject` only redirects `optOutTracking()`, while `setTrackingConsent('denied')` always means literally denied: ```ts onAcceptAll(() => setTrackingConsent('granted')) onRejectAnalyticsCookies(() => setTrackingConsent('cookieless')) onRejectAll(() => setTrackingConsent('denied')) ``` > **Cookieless is not automatically consent-exempt** > > Reading device characteristics (screen dimensions, locale, client hints) is > itself in scope of ePrivacy Art. 5(3) under EDPB Guidelines 2/2023, which extends > "access to information stored in terminal equipment" past cookies to > fingerprinting surfaces, and the server-side derivation above runs on IP and user > agent regardless of what the SDK stores. The mode minimizes what is collected and > stored; whether that is exempt where you operate is yours to decide as the > controller, and the SDK does not assume a lawful basis on your behalf. #### Withdrawing consent Leaving `'granted'` deletes the stored identity (profile, session, tab registry, the cross-subdomain cookie) and **drops any queued events unsent**, because transmitting them after the reader chose Reject would be a fresh act of processing on the data they just refused. (`reset()` is a logout rather than a consent change, so it still sends them once first.) #### Global Privacy Control [GPC](https://globalprivacycontrol.org/) is a browser-level "do not sell or share my data" signal, set by Brave (on by default), Firefox, DuckDuckGo and several privacy extensions. Opt in with `respectGpc: true` and a visitor sending it starts at your rejection state, no banner needed. Precedence is **seed -> GPC -> a choice made on your site**. GPC overrides `initial`, since it is the reader's own standing preference rather than your placeholder; a stored choice or a later `optInTracking()` overrides GPC, since accepting on your site is the more specific decision - and without that rule your banner would loop forever. Pair it with `persist: true`. Without persistence there is nowhere to record an acceptance: GPC re-resolves on every load, `isConsentPending()` stays `false` so your banner never shows, and an `optInTracking()` dies with the page - leaving a GPC visitor no way to accept at all. The SDK warns when it resolves consent from GPC with persistence off. #### Retention `maxAgeDays` (default 365) bounds every identifier the SDK stores: anonymous ID, external ID, session state, and the persisted consent choice. The deadline is **absolute**, stamped at first write and never extended by later visits, which is what CNIL's 13-month rule requires (`maxAgeDays: 390`). Lowering it reaches existing visitors too: a stored deadline is clamped to the current window on the next write. When the consent record lapses, `isConsentPending()` is `true` again and your banner is shown afresh - which is what makes a stored choice a preference with a shelf life rather than a permanent one. Two caveats. Chromium caps any cookie at 400 days and Safari's ITP caps script-written cookies far lower, so a longer `maxAgeDays` cannot outlive those. And the device-local record is a preference cache, not proof of consent - if you need to *demonstrate* consent under Art. 7(1), that belongs in your CMP or on your server, with a timestamp and the policy version. #### When a consent change doesn't take `setTrackingConsent()`, `optInTracking()` and `optOutTracking()` return `false` when the change did not fully take effect. Four cases are worth handling in a banner: - **Called before `init()`.** Nothing is applied: the one case where `false` really means "ignored". A banner racing initialization is the likeliest way to hit it; on the CDN install, wrap the call in `pug.ready()`. - **The state wasn't recognized.** CMPs speak their own vocabulary (`'reject'`, `'opt-out'`, a boolean, `null`). Rather than keep the previous state - which for someone clicking *Reject* would mean staying fully tracked - consent **fails closed to `'denied'`**. Map your CMP's values explicitly rather than relying on it. - **The choice couldn't be persisted** (`persist: true` with storage full or cookies blocked). It applies in memory, but the next load falls back to the `initial` seed - so an opt-out can quietly become a re-consent. - **A stored identifier couldn't be removed.** With [cross-subdomain identity](#cross-subdomain-identity) that means the cookie survived on the registrable domain and will resurface. ```ts if (!setTrackingConsent(choice)) { reportConsentFailure(getTrackingConsent()) } ``` Once `init()` has run a valid state is always applied in memory, so `false` then means "applied, but not fully durable" rather than "ignored". ### Flutter consent `pug_flutter` gates capture with **two** states (`TrackingConsent.granted` and `TrackingConsent.denied`) and **starts granted**. There is no cookieless middle state and no `isConsentPending()`: the Web SDK's three-state model is a browser concern, where the identifier is a cookie and the banner is a page element. ```dart await Pug.init( 'YOUR_PROJECT_ID', const PugOptions( apiKey: 'pub_YOUR_PUBLIC_KEY', trackingConsent: TrackingConsentConfig( defaultConsent: TrackingConsent.denied, persist: true, ), ), ); Pug.optInTracking(); Pug.optOutTracking(); Pug.isTrackingEnabled(); Pug.getTrackingConsent(); ``` Note the field is `defaultConsent`, not the Web SDK's `initial`. | Field | Type | Default | Description | |-------|------|---------|-------------| | `defaultConsent` | `TrackingConsent` | `granted` | Starting state, used when nothing is persisted. | | `persist` | `bool` | `false` | Write the choice to `__pug__consent__` and restore it on the next `Pug.init()`. Otherwise the choice lasts for the process and resets to `defaultConsent` on restart. | While denied, `Pug.track(...)`, the typed `Pug.track.*` methods, `Pug.identify(...)` and the automatic lifecycle and page-view events are all dropped, resuming once consent is granted. **Campaign capture is the exception**: deep-link attribution is still recorded locally while denied, and simply transmits nothing until consent arrives. Consent is independent of `dryRun`, which suppresses delivery without changing consent. Consent activity (denied drops, persistence failures) is reported only through the configured `PugLogger`, and the default `NoopPugLogger` is silent, so pass `DebugPrintPugLogger()` while you're wiring a prompt up. ### Redacting PII Four device-side controls keep PII out of captured events. **They are Web-only** - `pug_flutter` has no `beforeSend`, `redactUrlParams` or element-text capture, so keep PII out of the properties you pass to `Pug.track(...)` yourself. **`redactUrlParams`**: query and fragment params whose values are replaced with `redacted` in `$url`, `$referrer` and a form's `action`. It defaults to a built-in list of credentials and direct identifiers (`token`, `access_token`, `code`, `email`, `password`, ...) plus any param ending in `_token`, which covers framework reset-link names like `reset_password_token`. Pass an array to replace that list (matched case-insensitively by exact name), or `false` to disable redaction entirely. An empty array warns and keeps the default list - it would otherwise disable redaction exactly like `false`, but silently. **`beforeSend`**: redact, rewrite or drop each event just before it's sent. Mutate `autoProperties` / `customProperties` in place and return the event, return `null` to drop it, or return nothing at all. `$url`, `$referrer` and form actions arrive with `redactUrlParams` already applied, so this hook is for anything further - path segments, whole URLs, custom properties. ```ts import { init } from '@pug-sh/browser' init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY', redactUrlParams: ['email', 'invite', 'session_id'], beforeSend: (event) => { const url = event.autoProperties.$url if (typeof url === 'string') { // Mask path IDs the param-level redaction can't see. event.autoProperties.$url = url.replace(/\/orders\/\d+/, '/orders/:orderId') } if (event.kind === 'debug_ping') return null // drop entirely return event } }) ``` It runs synchronously on every event, so keep it cheap. It **fails closed**: a throw, a malformed return, or a non-function value drops the event (or every event) rather than sending it unredacted. `$projectId` / `$platform` / `$sdkVersion` are re-asserted afterwards, and `sessionId` / `distinctId` are not exposed. Unavailable on the [one-tag install](#one-tag-install) - `data-options` is JSON, which holds no functions. **Element text is the element's own text.** The click and dead-click trackers capture the clicked element's direct child text nodes - never the text of everything nested inside it. A click on a card reports the card's own label, not the name, email or order total in the elements it wraps: ```html
Open jane@example.com 4111 1111 1111 1111
``` Nested text is captured only when that element is itself what was clicked. `