Skip to Content
DocsSDKs

SDKs

Client and server libraries for sending events and managing identity from your apps. Every integration shares one mental model — initialize once, track events, identify users, reset on sign-out — whether you use a browser/app SDK, the server-side Node SDK, or call the API directly. The one exception is reset: the Node SDK has no ambient user or session to reset, so you name the distinctId on each call instead.

init(projectId, options)once at app startup
track(name, props)throughout the app
identify(id, traits)after sign-in
reset()on sign-out (client SDKs)

Choose your platform

PlatformUse it whenPackage
WebBrowser apps — React, Vue, Next.js, or plain JavaScript@pug-sh/browser
FlutterCross-platform mobile & desktop appspug_flutter
NodeA Node.js backend that sends events and reads analytics@pug-sh/node
HTTP APIAny 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

The Web SDK ships as compiled JavaScript plus type definitions for bundlers (Vite, webpack, esbuild, Next.js, …) and runs in the browser. There is no standalone <script>/CDN build.

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.tsx
'use client'

import { init } from '@pug-sh/browser'
import { useEffect } from 'react'

export function PugProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    init(process.env.NEXT_PUBLIC_PUG_PROJECT_ID!, {
      apiKey: process.env.NEXT_PUBLIC_PUG_PUBLIC_KEY!
    })
  }, [])
  return <>{children}</>
}

The App Router also doesn’t do a full History push on client-side navigation, so auto-tracking fires page_view only on the initial load — track later navigations explicitly with track('page_view') in a usePathname() effect (see Auto-tracking).

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

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>) and your keys in the API Keys section of your project Overview.

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.

OptionTypeDefaultDescription
apiKeystringrequiredPublic key (pub_…), sent as x-api-key.
endpointstringPug CloudAPI base URL. Defaults to Pug Cloud (https://api.pugs.dev) — set it only when self-hosting.
batchPartial<BatchConfig>maxSize 10, maxWaitMs 5000, maxQueueSize 1000Queue size and flush timing; persisted to localStorage.
sessionSessionConfigidleTimeoutMinutes 30, maxSessionMinutes 1440Session idle and max-duration timeouts (minutes).
autoCaptureboolean | AutoCaptureSelectiontrueAutomatic capture: true enables all listeners, false none, or an object is a per-listener allowlist. See Auto-tracking.
trackingConsent'granted' | 'denied' | { default?, persist? }'granted'Consent gate — while denied, listeners stay off and track()/identify() are dropped. See Consent and privacy.
crossSubdomainTrackingboolean | { domain }falseShare identity across subdomains via a first-party cookie. Off by default — it relaxes origin isolation to same-site.
sanitizeUrl(url) => stringRewrite $url / $referrer / form actions before they leave the device; fails closed. See Consent and privacy.
dryRunbooleanfalseLog 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.

Endpoint defaults to Pug Cloud. Every SDK defaults endpoint to https://api.pugs.dev — so you only set it when self-hosting, pointing it at your own server (the dev server listens on port 3000).

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:

OptionTypeDescription
immediatebooleanBypass the batch queue and send right away.
timestampnumberOverride 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 })

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.

FunctionClears identityNew device IDNew session ID
rotate()NoNoYes
reset()YesYesYes

traits is plain JSON, not PropertyValue-wrapped. Unlike event customProperties (which need {"plan":{"stringValue":"pro"}}), identify traits is a google.protobuf.Struct — pass bare JSON values ("plan": "pro", "seats": 5). The SDKs send traits as-is; only event properties get the wrapper.

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 event
session ID created
Events within timeout
same session ID
Idle past timeout
session expires
Next event
new session ID (identity kept)

The Web SDK syncs the session across tabs via localStorage; Flutter persists it to SharedPreferences and restores it on cold start (re-checking the timeouts). The Node SDK uses a single session ID for the instance’s lifetime — override it per call with track(..., { sessionId }), since sessions are a client concept. Configure the session timeouts at init on the Web (idleTimeoutMinutes/maxSessionMinutes) and Flutter (idleTimeout/maxDuration) SDKs — see Initialize. 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 }
})
TrackerAllowlist keyEvent(s)Captured properties
Page viewspageViewpage_viewauto-properties only ($url, $referrer, …)
Clicksclickclicktag, id, class, text (≤50 chars), x, y
Scrollscrollscrollpercent (0–100), scrollY
Formsformform_start, form_submitform metadata only — formId, formName (both), plus action on form_submit. Never field values
Rage clicksrageClickrage_clickclickCount, element, x, y
Dead clicksdeadClickdead_clickelement, 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 Consent and privacy.

Auto-property keys are camelCase on the client SDKs — $utmSource, $pageTitle, $screenWidth. The only snake_case keys are the two server-set bot signals, $bot_score and $verified_bot. See Auto-properties for the full per-platform catalog.

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

Gate all capture behind consent. Start denied — trackingConsent: { default: 'denied', persist: true } remembers the choice across reloads — then flip it from your cookie banner. While denied, automatic listeners aren’t attached and manual track() / identify() calls are dropped (not queued for later replay).

import { init, optInTracking, optOutTracking, isTrackingEnabled } from '@pug-sh/browser'

// Start denied and remember the choice across reloads.
init('YOUR_PROJECT_ID', {
  apiKey: 'pub_YOUR_PUBLIC_KEY',
  trackingConsent: { default: 'denied', persist: true }
})

// After the user accepts — attaches listeners, applies the stored autoCapture selection.
optInTracking()

// After the user declines — tears listeners down, drops future track()/identify().
optOutTracking()

isTrackingEnabled() // → true once consent is granted
FunctionDescription
optInTracking()Grant consent, attach listeners (applying the stored autoCapture selection), allow track() / identify().
optOutTracking()Revoke consent, tear down listeners, drop future track() / identify(), and clear the stored anonymous ID / session (a later opt-in starts fresh).
isTrackingEnabled()true when consent is granted (independent of dryRun).
getTrackingConsent()'granted' or 'denied'.
setAutoCapture(selection)Change the automatic-listener allowlist at runtime — applied immediately when granted, deferred until opt-in when denied.

Redacting PII

Two device-side controls keep PII out of captured events:

sanitizeUrl — an init() function that rewrites $url, $referrer, and captured form actions before they’re sent. It fails closed: if it throws or returns a non-string, the URL is dropped to an empty string rather than sent raw. It covers URL fields only — don’t put PII in UTM params, which are parsed separately.

import { init } from '@pug-sh/browser'

init('YOUR_PROJECT_ID', {
  apiKey: 'pub_YOUR_PUBLIC_KEY',
  // Rewrite $url, $referrer, and captured form actions before they leave the device.
  sanitizeUrl: (url) => {
    const u = new URL(url, window.location.origin)
    u.pathname = u.pathname.replace(/\/orders\/\d+/, '/orders/:orderId') // mask IDs
    u.searchParams.delete('email')                                       // strip PII params
    return u.toString()
  }
})

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)
}
NamespaceMethods
pug.profilesget(id), getByExternalId(externalId), delete(id), list(req?) — an auto-paginating async iterator
pug.activityfeed, eventExplorer, heatmap, profileStats, filterSchema, propertyValues
pug.insightsquery, 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 statusConnect codeMeaning
400invalid_argumentValidation failed (bad field value, reserved prefix, etc.)
401unauthenticatedMissing or invalid x-api-key
429resource_exhaustedRate limit exceeded
500internalServer error — safe to retry with backoff

Validation rules:

RuleDetail
Batch sizeMax 1000 events per BatchCreate request
kindMust not start with pug. (reserved)
customProperties keysMust not start with $
autoProperties keysMust start with $
$bot_score, $verified_botServer-only; any client-set value is stripped
stringValue lengthThe 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": {}
    }]
  }'

Further reading

Last updated on