---
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.0.4/`);
the file is immutable and edge-cached, so bump it when you upgrade. 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 optInTracking optOutTracking ' +
  'isTrackingEnabled getTrackingConsent isConsentPending setTrackingConsent 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.0.4/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.0.4/pug.min.js"
  data-project-id="YOUR_PROJECT_ID"
  data-api-key="pub_YOUR_PUBLIC_KEY"
  data-options='{"trackingConsent":{"initial":"denied","persist":true}}'
></script>
```

`data-options` is JSON, so it holds no functions — `beforeSend` is unavailable
on this path. 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).

Verify by opening your dashboard's **Live** view — an auto-tracked `page_view`
appears within ~15 seconds.

### 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.3
```

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

### 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.

### 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.

## 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. |
| `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. |
| `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. |
| `dryRun` | `boolean` | `false` | Log events to the console without sending. |

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**
>
> Earlier versions took a `sanitizeUrl` callback. It is gone — passing it now logs
> a warning and is ignored. Known-sensitive query and fragment params are redacted
> by default (`redactUrlParams`), and any further masking belongs in `beforeSend`.

### 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. See [Tracking consent](#tracking-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` 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.`. The response is
`{ "accepted": <n> }`. 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, `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. `reset()` clears the stored
identity and starts a new anonymous session — it does **not** delete the profile
in the dashboard.

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

### 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.

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

## 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, scroll: false }
})
```

| Tracker | Allowlist key | Event(s) | Captured properties |
|---------|---------------|----------|---------------------|
| Page views | `pageView` | `page_view` | auto-properties only (`$url`, `$referrer`, …) |
| Clicks | `click` | `click` | `tag`, `id`, `class`, `text` (≤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` (≤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).

### 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 gates capture at init;
over the Node SDK or raw HTTP, redact PII before you call `track()`.)

### Tracking consent

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()`. |
| `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. |

| Function | Description |
|---|---|
| `optInTracking()` | Grant consent, attach listeners (applying the stored `autoCapture` selection), allow `track()` / `identify()`. |
| `optOutTracking()` | Revoke consent, resolving to `onReject`. Tears down listeners, drops future `track()` / `identify()`, and clears the stored anonymous ID / session. |
| `setTrackingConsent(state)` | Set any of the three states explicitly. `setTrackingConsent('denied')` always means literally denied, regardless of `onReject`. |
| `isTrackingEnabled()` | `true` when events are being sent (independent of `dryRun`). |
| `getTrackingConsent()` | The current state, or `undefined` before `init()`. |
| `isConsentPending()` | `true` while the reader hasn't chosen — gate your banner on this. |
| `setAutoCapture(selection)` | Change the automatic-listener allowlist at runtime — applied immediately when granted, deferred until opt-in when denied. |

Stored identifiers expire after `maxAgeDays` (default 365). When the consent
record lapses, `isConsentPending()` is true again and your banner is shown
afresh.

### Redacting PII

Three device-side controls keep PII out of captured events.

**`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.

**`data-pug-no-capture`** — an HTML attribute that 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. Put it on an ancestor of every clickable element; `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>
```

## 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
