Skip to content

SDKs

Client and server libraries for sending events and managing identity - Web, Flutter, Node, and the raw HTTP API.

Updated View as Markdown

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

Install

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

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 or a one-tag install.

With a bundler, import named exports from the package root:

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:

components/PugProvider.tsxtsx
'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).

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

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

<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 (trackingConsent, autoCapture, …).

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

Prompt
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 pub add pug_flutter

View pug_flutter on pub.dev

flutter pub add pug_flutter adds it to pubspec.yaml and fetches it; to pin the version yourself, add it under dependencies instead:

pubspec.yamlyaml
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:

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

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.

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

The Node SDK is server-side and ships as ESM - it needs Node.js 18+. Import the Pug class:

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.

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

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.

Prompt
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 - if you don’t have yours, issue a new one.

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.
trackingConsent TrackingConsent | TrackingConsentConfig 'cookieless' Consent gate. See 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.
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.
beforeSend BeforeSendFn - Redact, rewrite or drop each event before it is sent. Fails closed. See 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.

Tear down and re-init with destroy() (flushes the queue, removes listeners). reset() and rotate() are covered under Identify and sessions.

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 Durations.
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.
dryRun bool false Log via PugLogger without sending.

Advanced overrides (logger, storage, transport, autoPropertyProvider, linkProvider) let you inject test doubles or custom collection.

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.

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

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 add typed properties and richer dashboards. Names starting with pug. are reserved.

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()).
// Conversion that must arrive before a redirect
track('purchase', { productId: 'sku_789', amount: 99, currency: 'USD' }, { immediate: true })
// 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.
Pug.track.purchase(
  productId: 'sku-789', amount: 99.0, currency: 'USD',
  options: const TrackOptions(immediate: true),
);
// 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:

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.

Endpoint: POST /sdk.events.v1.EventsService/BatchCreate (max 1000 events per request).

{
  "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 for status codes and full examples, and the Events API reference 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.

// 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 it means the cookie survived on the registrable domain and the next person inherits it.

if (!reset()) {
  showSignOutWarning()
}
Function Clears identity New device ID New session ID
rotate() No No Yes
reset() Yes Yes Yes
// 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
// 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.

Endpoint: POST /sdk.profiles.v1.ProfilesSDKService/Identify

{
  "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.

First eventsession ID created
Events within timeoutsame session ID
Idle past timeoutsession expires
Next eventnew 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. For the anonymous->identified model, see Core concepts.

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.

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.

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 (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, and the server attaches enrichment properties to every event regardless of platform.

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():

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:

autoCapture: { pageView: true, scroll: enableScroll || undefined }

Plain JavaScript and the 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, 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, 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_views manually. To gate all capture behind a consent banner, see 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.

Navigation is captured when you wire a PugRouteObserver into your MaterialApp:

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

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.

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.

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

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.

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

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:

onAcceptAll(() => setTrackingConsent('granted'))
onRejectAnalyticsCookies(() => setTrackingConsent('cookieless'))
onRejectAll(() => setTrackingConsent('denied'))

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

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 that means the cookie survived on the registrable domain and will resurface.
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”.

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.

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.

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

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

<!-- 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:

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.

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

sanitizeUrl is removed, replaced by beforeSend, 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.

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

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.

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 for field-by-field schemas. From a non-Node backend, call those services directly with your private key (see 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:

{
  "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 -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": {}
    }]
  }'
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}
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

Navigation

Type to search...

↑↓ navigate↵ selectEsc close