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.
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/browserThe 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:
'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.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.
<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:
<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.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 pub add pug_flutterflutter pub add pug_flutter adds it to pubspec.yaml and fetches it; to pin
the version yourself, add it under dependencies instead:
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:
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.)
npm install @pug-sh/node
# or: pnpm add @pug-sh/node · yarn add @pug-sh/node · bun add @pug-sh/nodeThe 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.
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 — 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. |
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. |
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. |
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.
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. See 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.
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 lostflush() 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.. The response is
{ "accepted": <n> }. 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: '[email protected]', 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 |
// On sign-in
await Pug.identify('user-123', traits: {'email': '[email protected]', '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: '[email protected]', 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": "[email protected]", "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. For the
anonymous→identified model, see Core 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, 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, 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_views manually. To gate all capture behind a consent banner, see
Tracking consent.
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.
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.
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 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.
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.
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.
<!-- The click still counts, but "[email protected]" is never captured. -->
<button data-pug-no-capture>Account: [email protected]</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.
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
- Quickstart — your first event in five minutes
- Core concepts — events, profiles, sessions, identity
- Well-known events — the typed event catalog
- Auto-properties — every
$-property the SDKs and server attach - Authentication — public vs. private keys, rotation
- Self-hosting — run the Pug stack on your own infrastructure
- API reference: Events · Profiles · RPC services
- SDK source: browser · node · flutter