---
title: "SDKs"
description: "Client and server libraries for sending events and managing identity - Web, Flutter, Node, and the raw HTTP API."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.pug.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# SDKs

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.2/`);
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 `<head>`. 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
<script>
  !(function (w, d) {
    if (w.pug) { if (!w.pug._q) console.warn('[Pug SDK] window.pug already defined by another script; not loaded.'); return; }
    var q = [];
    var pug = (w.pug = { _q: q, _v: 1 });
    var methods = ('init track identify reset destroy setAutoCapture setTrackingConsent optInTracking ' +
      'optOutTracking isTrackingEnabled getTrackingConsent isConsentPending rotate ready').split(' ');
    methods.forEach(function (m) {
      pug[m] = function () { if (q.length < 1000) q.push([m, [].slice.call(arguments)]); };
    });
    var s = d.createElement('script');
    s.async = true;
    s.src = 'https://cdn.pugs.dev/v0.1.2/pug.min.js';
    s.onerror = function () { console.warn('[Pug SDK] Failed to load ' + s.src); };
    d.head.appendChild(s);
  })(window, document);

  // Always init first - calls queued before init() are dropped by the SDK.
  pug.init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY' });
</script>
```

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
<script>
  pug.track('signup', { plan: 'pro' })   // queued, replayed in order after load
  pug.ready(function () {
    // Runs once the SDK is live - safe to read state or await here.
    if (pug.isTrackingEnabled()) pug.identify('user-123', { plan: 'pro' })
  })
</script>
```

#### 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
<script
  async
  src="https://cdn.pugs.dev/v0.1.2/pug.min.js"
  data-project-id="YOUR_PROJECT_ID"
  data-api-key="pub_YOUR_PUBLIC_KEY"
  data-options='{"trackingConsent":{"initial":"cookieless","persist":true},"maxAgeDays":390}'
></script>
```

`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.<event> 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/<projectId>`), 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. |
| `excludeAutomatedBrowsers` | `boolean` | `false` | Send nothing at all from browsers driven by automation (Playwright, Puppeteer, Selenium, headless Chrome). Off by default - bot traffic is tagged server-side rather than dropped. See [Automated browsers](#automated-browsers). |

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<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Pug.init(
    'YOUR_PROJECT_ID',
    const PugOptions(
      apiKey: 'pub_your_public_key',
    ),
  );

  runApp(const MyApp());
}
```

`Pug.init` returns `Future<void>` 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<BatchConfig>` | `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(distinctId, kind, props?, options?)` options:

| Option | Type | Description |
| - | - | - |
| `timestamp` | `number` | Override the event time, as **epoch milliseconds** (e.g. `Date.now()`). |
| `sessionId` | `string` | Use this session ID for the call - otherwise the instance uses one for its lifetime. |
| `location` | `EventLocation` | Where the **visitor** is. Without it a server event carries no geo at all. See [Visitor location](#visitor-location). |

**Property values** accept the same JSON scalar types as the other SDKs.

#### Visitor location

Pug resolves geo from CDN headers, which describe whoever opened the
connection - your backend, on every request this SDK makes. So the server skips
geo enrichment on a private key entirely: an event carries the location you give
it, or none at all. `location` (added in **0.0.6**) is how you give it one.

```ts
pug.track('user-123', 'purchase',
  { productId: 'sku_123', amount: 29.99, currency: 'USD' },
  { location: { country: 'DE', city: 'Berlin' } })
```

| Field | Type | Notes |
| - | - | - |
| `country` | `string` | ISO 3166-1 alpha-2 (`DE`). Case and surrounding spaces are normalized; a code Pug doesn't recognize is dropped. |
| `continent`, `region`, `city`, `postalCode`, `metroCode` | `string` | Sent as given, trimmed. |
| `timezone` | `string` | IANA name (`Europe/Berlin`). |
| `latitude`, `longitude` | `number` | Decimal degrees, `-90` to `90` and `-180` to `180`. Sent only as a pair - one without the other reads as a real position with the other axis at `0`, so neither is sent. |

Each field becomes the matching `$`-prefixed
[auto-property](/reference/auto-properties#geo-enrichment-cloudflare): `city`
lands as `$city`. That makes `location` the only way to set an auto-property
from this SDK - `props` become **custom** properties, which may not start with
`$`.

A field that is absent, `null` or empty is skipped silently. One that looks like
a mistake - unknown key, wrong type, a country code Pug doesn't know, a
coordinate out of range - is dropped with a warning and **the rest of the event
still sends**. Nothing is merged at ingestion, so a dropped field is a permanent
hole rather than one the edge fills in later.

### 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": <n> }`, 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<void>` 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.

1. **First event** (session ID created)
2. **Events within timeout** (same session ID)
3. **Idle past timeout** (session expires)
4. **Next event** (new session ID (identity kept))

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_<projectId>_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
<!-- Click the row: text is "Open", not "Open jane@example.com 4111 1111 1111 1111". -->
<div class="row">
  Open
  <span>jane@example.com</span>
  <span>4111 1111 1111 1111</span>
</div>
```

Nested text is captured only when that element is itself what was clicked.
`<textarea>` and `contenteditable` regions never report text at all, since their
content is whatever the user typed - including a click landing on an element
*inside* an editable region, which is what a rich-text editor's markup produces.
An explicit `contenteditable="false"` island inside one is not user input, so its
own text is still captured.

**`data-pug-no-capture`**: an HTML attribute for the case the own-text rule
doesn't cover, where the sensitive value sits directly in the clickable element.
It blanks captured element **text** for that element and everything inside it,
while still recording structural fields (`tag`, `id`, `class`, coordinates) so
the interaction still counts. `id` and `class` are still sent, so keep PII out of
those too.

```html
<!-- The click still counts, but "jane@example.com" is never captured. -->
<button data-pug-no-capture>Account: jane@example.com</button>

<!-- On a container, it covers every element inside. -->
<div data-pug-no-capture>
  <span>Card ending 4242</span>
  <button>Pay $49.00</button>
</div>
```

### Automated browsers

**Web only.** A browser driven by automation - WebDriver/CDP (Playwright,
Puppeteer, Selenium) or a headless Chrome build - is tracked like any other
visitor by default. That matches the rest of Pug, which tags bot traffic
server-side rather than dropping it, and it means an end-to-end test asserting
the SDK fired still sees its event.

`excludeAutomatedBrowsers` stops collecting it at all. `init()` warns once and
returns without attaching a listener, writing to storage or opening a
connection:

```ts
init('YOUR_PROJECT_ID', {
  apiKey: 'pub_YOUR_PUBLIC_KEY',
  excludeAutomatedBrowsers: true,
})
```

What that buys is cost, not accuracy. A bot-tagged event is still stored and
still counts toward the project's event total, so a suite running on every
deploy lands on the bill as well as in dashboards; not sending is the only
version that doesn't.

> **It also silences the test that checks your analytics**
>
> The bail is unconditional, so a spec asserting a `page_view` reached the network
> will never see one while this is on. Leave it off in whichever suite tests your
> Pug integration, and on everywhere else.

After the bail the SDK is inert: every method does nothing and logs to
`console.debug`, and `optInTracking()`, `optOutTracking()`,
`setTrackingConsent()` and `reset()` return `false`. The two queries keep
answering honestly rather than reporting suppression - `isConsentPending()`
still returns `true`, `getTrackingConsent()` still `undefined` - so a consent
banner behaves under test exactly as it does in production. Identity stored by
an earlier visit is left alone: the flag means "touch nothing on this browser",
so a `trackingConsent: 'denied'` seed skips its usual purge here.

Detection reads `navigator.webdriver`, plus a `HeadlessChrome` token in the user
agent or in `navigator.userAgentData.brands`. Each is read independently, so one
unreadable signal cannot decide for the others, and a signal that throws (a
privacy extension shadowing `navigator`) counts as a real visitor rather than
silencing one. A driver that hides all three is not caught here - that is what
Pug's server-side bot signals are for.

## Upgrading

### Web SDK v0.1.0

The privacy release. Most of it changes **runtime** behaviour, so it reaches
JavaScript and [one-tag](#one-tag-install) installs as well as TypeScript ones.

- **Consent now defaults to `'cookieless'`, not `'granted'`.** An install that
  doesn't configure `trackingConsent` keeps sending events, but no longer writes
  a session, an anonymous ID or a cross-subdomain cookie, and `identify()` is a
  no-op. Pass `trackingConsent: 'granted'` to keep the old behaviour - and
  satisfy yourself you have a basis for it, since it stores identifiers before
  any banner is answered.
- **Stored identifiers now expire** after [`maxAgeDays`](#retention) (default
  365, absolute rather than sliding), and the stored format changed. Values
  written by an earlier version are unreadable, so they're treated as absent and
  **deleted** on first read: existing visitors get a fresh anonymous ID once, on
  upgrade, and any `identify()`ed external ID left by the old build leaves the
  device. A persisted consent choice is the exception - a bare
  `granted`/`denied`/`cookieless` from an earlier build is adopted and
  re-persisted with a deadline, so a recorded opt-out survives.
- **Sensitive URL params are redacted by default** in `$url`, `$referrer` and a
  form's `action`. Pass [`redactUrlParams: false`](#redacting-pii) for the old
  verbatim behaviour.
- **Queued events are dropped, not sent, on consent withdrawal.** `reset()` still
  sends them once first.
- **`click` and `dead_click` `text` is the element's own text**, not its whole
  subtree - text captured from wrapper elements gets shorter and stops carrying
  nested content.
- **`$pageTitle` rides `page_view` only**, where it used to ride every event.
- **`crossSubdomainTracking.maxAgeDays` moved to the top-level `maxAgeDays`**,
  which bounds `localStorage` too. On a one-tag install this changes behaviour:
  an object with no `domain` used to auto-discover the registrable domain and now
  resolves to *off* with a warning - pass `true` to keep sharing identity.
- `init()` warns when `endpoint` is not `https` (localhost aside).

> **Roll every CDN page over together**
>
> Each [CDN](#loader-snippet) page pins its own `cdn.pugs.dev/vX.Y.Z` URL, and the
> two storage formats don't understand each other - in **any** mode, including the
> default, where same-origin pages on different versions thrash the anonymous ID
> back and forth. It is worse for an `identify()`ed external ID: the old build has
> no envelope to unwrap, so it adopts the raw stored string as the profile's
> `distinctId` and sends it on every event until the rollout finishes.

**`sanitizeUrl` is removed**, replaced by [`beforeSend`](#redacting-pii), which
reaches every property rather than URL fields only. TypeScript consumers get a
compile error; JavaScript and one-tag installs get only a warning, so what is
silently lost is whatever structural masking `sanitizeUrl` added on top of the
default param redaction.

```ts
init('YOUR_PROJECT_ID', {
  apiKey: 'pub_YOUR_PUBLIC_KEY',
  beforeSend: (event) => {
    event.autoProperties.$url = maskUrl(event.autoProperties.$url)
    event.autoProperties.$referrer = maskUrl(event.autoProperties.$referrer)
    const action = event.customProperties.action
    if (event.kind === 'form_submit' && typeof action === 'string') {
      event.customProperties.action = maskUrl(action)
    }
    return event
  }
})
```

Two differences to carry into `maskUrl` itself. It no longer skips `''`, so a
base-relative masker would resolve a referrer-less page view into a fabricated
self-referral - guard with `if (!url) return url`. And `customProperties` values
are typed as whatever you passed to `track()`, so narrow with `typeof` before
treating one as a string, as above. Note `action` on `form_submit`, which
`sanitizeUrl` used to cover for you.

The rest are **compile-time** breaks for TypeScript consumers only:

| Change | What breaks | Fix |
|---|---|---|
| `autoCapture` values are `true`, not `boolean` | `{ scroll: false }` and any `boolean`-typed value | List only what you want enabled; for a runtime value write `scroll: flag \|\| undefined` |
| `track()` is one signature, not two overloads | A wrong type on a well-known field now errors instead of silently compiling | Correct the property type: the error names it. This also surfaces `sizeBytes`, which is a `bigint`: pass `1024n`, not `1024` |
| `getTrackingConsent()` returns `TrackingConsent \| undefined` | Code assuming a non-optional return under `strictNullChecks` | Handle `undefined`, which means "called before `init()`" |
| `TrackingConsent` has a third member, `'cookieless'` | Exhaustive `switch`es and `Record<TrackingConsent, ...>` maps | Handle `'cookieless'` - events flow without identity |
| `optOutTracking()` applies `trackingConsent.onReject` | Nothing by default (`onReject` defaults to `'denied'`) | Opt in with `onReject: 'cookieless'` to keep identity-free counts after a rejection |
| `crossSubdomainTracking: { maxAgeDays }` | The object arm is `{ domain: string }` only | Move it to the top-level `maxAgeDays` |
| `crossSubdomainTracking` is enabled only by `true` or `{ domain }` | A `data-options` value of `{}`, `"true"`, `"false"` or a number no longer enables it (all four used to; `"false"` **enabled** it) | State the opt-in explicitly |
| `TrackingConsentConfig.default` renamed to `initial` | `trackingConsent: { default: ... }` | Rename the key. A stale `default` now warns and **fails closed to `'denied'`** - including in `data-options` JSON, which no compiler checks |

### Flutter SDK v0.0.4

Nothing here breaks compilation - the changes are all in the
[auto-properties](/reference/auto-properties) each event carries.

- **`$deviceType` is new**: `tv`, `mobile` or `desktop`. Android TV is detected
  from the device's system features rather than the OS, which reports plain
  `android` there, so `$platform` stays `android` and matches the other SDKs.
  Omitted rather than guessed on web and on any target it can't classify.
- **`$deviceModel` no longer ships placeholders.** `device_info_plus` returns
  literal `"Unknown device"`/`"Unknown Model"` strings when its marketing-name
  lookup misses; those now fall back to the device identifier (`iPhone`,
  `MacBookPro18,3`). macOS was affected on any Mac absent from the plugin's table.
- **`$osVersion` is omitted** when the device-info payload carries no version,
  rather than sent as an empty string that overwrote the fallback.
- **`$deviceManufacturer` is no longer sent on Windows**, which exposes no
  manufacturer - the value being sent was the host name.
- `$platform` now derives from `Platform.operatingSystem` rather than
  `defaultTargetPlatform`. No value changes on any supported platform.

> **v0.0.3 changed $platform values - check your saved queries**
>
> Before 0.0.3 the Flutter SDK sent `defaultTargetPlatform.name`, keeping the
> enum's casing: `iOS` and `macOS` where every other Pug SDK sends `ios` and
> `macos`. The backend promotes `$platform` into its own column verbatim, so the
> mismatch split one platform into per-SDK buckets in any cross-SDK breakdown.
> Flutter **web** separately reported the browser's host OS, making a web app on an
> iPhone indistinguishable from native iOS traffic.
>
> Both are fixed, but the fix is a **data** change: dashboards, filters and saved
> segments matching the old `iOS`/`macOS` casing (or treating Flutter web traffic
> as iOS) need updating to the lowercase set.

## Server-side reads

The **Node** SDK's private key authorizes ingestion *and* **reads** - the same
insights, profiles, and activity data the dashboard shows. Reads are
request/response and **throw `PugError`** (which carries the Connect `code` and
`cause`); they run in your control flow, so you own timeout and retry. Ingestion
(`track` / `identify`) still never throws.

```ts
import { PugError } from '@pug-sh/node'

try {
  // Look up a profile by your external ID.
  const profile = await pug.profiles.getByExternalId('user-123')

  // Auto-paginating async iterator over every matching profile.
  for await (const p of pug.profiles.list()) {
    console.log(p.externalId)
  }

  // Run an insights query - the same spec the dashboard uses.
  const result = await pug.insights.query({ /* spec, timeRange, granularity */ })
} catch (err) {
  // Reads throw; ingestion (track/identify) never does.
  if (err instanceof PugError) console.error(err.code, err.message)
}
```

| Namespace | Methods |
|-----------|---------|
| `pug.profiles` | `get(id)`, `getByExternalId(externalId)`, `delete(id)`, `list(req?)`: an auto-paginating async iterator |
| `pug.activity` | `feed`, `eventExplorer`, `heatmap`, `profileStats`, `filterSchema`, `propertyValues` |
| `pug.insights` | `query`, `segmentUsers`, `filterSchema`, `propertyValues` |

The request/response shapes match the `shared.*` Connect services - see the
[API reference](/api) for field-by-field schemas. From a non-Node backend, call
those services directly with your private key (see
[Authentication](/get-started/authentication)).

## Errors and the raw HTTP API

The SDKs surface failures as thrown or logged errors and retry transient ones
for you - in the Node SDK the read methods throw `PugError`, which carries the
Connect `code` below; over raw HTTP you handle them yourself. Connect uses
standard HTTP status codes, and on error the body is:

```json
{
  "code": "invalid_argument",
  "message": "event kind must not use reserved prefix 'pug.'"
}
```

| HTTP status | Connect code | Meaning |
|-------------|--------------|---------|
| 400 | `invalid_argument` | Validation failed (bad field value, reserved prefix, etc.) |
| 401 | `unauthenticated` | Missing or invalid `x-api-key` |
| 429 | `resource_exhausted` | Rate limit exceeded |
| 500 | `internal` | Server error: safe to retry with backoff |

**Validation rules:**

| Rule | Detail |
|------|--------|
| Batch size | Max **1000 events** per `BatchCreate` request |
| `kind` | Must not start with `pug.` (reserved) |
| `customProperties` keys | Must **not** start with `$` |
| `autoProperties` keys | Must start with `$` |
| `$bot_score`, `$verified_bot` | Server-only; any client-set value is stripped |
| `stringValue` length | The SDKs truncate strings to 1024 bytes client-side |

### Examples

### cURL

```bash
curl -X POST https://api.pugs.dev/sdk.events.v1.EventsService/BatchCreate \
  -H 'Content-Type: application/json' \
  -H 'Connect-Protocol-Version: 1' \
  -H 'x-api-key: pub_your_public_key' \
  -d '{
    "events": [{
      "eventId": "01966b9e-1234-7abc-abcd-0123456789ab",
      "distinctId": "anon-abc123",
      "kind": "page_view",
      "sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "occurTime": "2026-06-05T10:00:00Z",
      "customProperties": { "path": { "stringValue": "/pricing" } },
      "autoProperties": {}
    }]
  }'
```

### Python

```python
import uuid
from datetime import datetime, timezone
import requests

API_KEY = "pub_your_public_key"
BASE_URL = "https://api.pugs.dev"

def send_event(distinct_id, kind, session_id, custom_props):
    event = {
        "eventId": str(uuid.uuid4()),  # swap for uuidv7() if available
        "distinctId": distinct_id,
        "kind": kind,
        "sessionId": session_id,
        "occurTime": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "customProperties": custom_props,
        "autoProperties": {},
    }
    resp = requests.post(
        f"{BASE_URL}/sdk.events.v1.EventsService/BatchCreate",
        headers={
            "Content-Type": "application/json",
            "Connect-Protocol-Version": "1",
            "x-api-key": API_KEY,
        },
        json={"events": [event]},
        timeout=5,
    )
    resp.raise_for_status()
    return resp.json()

result = send_event(
    "anon-abc123", "signup", "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    {"plan": {"stringValue": "pro"}, "seats": {"intValue": "5"}},  # int64 as a JSON string
)
print(result)  # {"accepted": 1}
```

### Go

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"github.com/google/uuid"
)

const (
	apiKey  = "pub_your_public_key"
	baseURL = "https://api.pugs.dev"
)

// propertyValue is the Connect-JSON encoding of the protobuf oneof.
type propertyValue struct {
	StringValue    *string  `json:"stringValue,omitempty"`
	IntValue       *string  `json:"intValue,omitempty"`       // int64 as a JSON string
	DoubleValue    *float64 `json:"doubleValue,omitempty"`
	BoolValue      *bool    `json:"boolValue,omitempty"`
	TimestampValue *string  `json:"timestampValue,omitempty"` // RFC 3339
}

func strVal(s string) propertyValue { return propertyValue{StringValue: &s} }

type event struct {
	EventID          string                   `json:"eventId"`
	DistinctID       string                   `json:"distinctId"`
	Kind             string                   `json:"kind"`
	SessionID        string                   `json:"sessionId"`
	OccurTime        string                   `json:"occurTime"`
	CustomProperties map[string]propertyValue `json:"customProperties"`
	AutoProperties   map[string]propertyValue `json:"autoProperties"`
}

func main() {
	body, _ := json.Marshal(map[string]any{
		"events": []event{{
			EventID:          uuid.New().String(),
			DistinctID:       "anon-abc123",
			Kind:             "signup",
			SessionID:        "f47ac10b-58cc-4372-a567-0e02b2c3d479",
			OccurTime:        time.Now().UTC().Format(time.RFC3339),
			CustomProperties: map[string]propertyValue{"plan": strVal("pro")},
			AutoProperties:   map[string]propertyValue{},
		}},
	})

	req, _ := http.NewRequest(http.MethodPost,
		baseURL+"/sdk.events.v1.EventsService/BatchCreate", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Connect-Protocol-Version", "1")
	req.Header.Set("x-api-key", apiKey)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	fmt.Println("status:", resp.Status)
}
```

## Further reading

- [Quickstart](/get-started): your first event in five minutes
- [Core concepts](/get-started/concepts): events, profiles, sessions, identity
- [Well-known events](/reference/well-known-events): the typed event catalog
- [Auto-properties](/reference/auto-properties): every `$`-property the SDKs and server attach
- [Authentication](/get-started/authentication): public vs. private keys, rotation
- [Self-hosting](/self-hosting): run the Pug stack on your own infrastructure
- **API reference:** [Events](/api/events) | [Profiles](/api/profiles) | [RPC services](/reference/rpc-services)
- **SDK source:** [browser](https://github.com/pug-sh/sdk-browser) | [node](https://github.com/pug-sh/sdk-node) | [flutter](https://github.com/pug-sh/sdk-flutter)

Source: https://docs.pug.sh/sdks/index.mdx
