---
title: "Node SDK"
description: "Send events and read profiles, activity and insights from a Node.js backend with @pug-sh/node and a private key."
---

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

# Node SDK

`@pug-sh/node` sends events from your Node.js backend **and** reads analytics
back - profiles, activity and insights. It authenticates with your **private
key** (`prv_...`), so it belongs on the server only. It follows the
[same model as every Pug SDK](/sdks) with one exception: a server has no ambient
user or session to reset, so you name the `distinctId` on each call instead.

## Install

```bash
npm install @pug-sh/node
# or: pnpm add @pug-sh/node | yarn add @pug-sh/node | bun add @pug-sh/node
```

[View `@pug-sh/node` on npm](https://www.npmjs.com/package/@pug-sh/node)

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

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

Unlike the client SDKs it authenticates with your **private key** (`prv_...`), so
keep it on the server - in an environment variable, never in code shipped to a
browser or app. Construct one instance at boot and reuse it across requests.

Working with a coding agent? Paste this instead - it points the agent at the
markdown copy of this page, so it works from the reference below rather than
from memory. More on the approach in [Install with AI](/get-started/ai-setup).

```text
Add Pug product analytics to this Node backend.

Read these first - they are the current source of truth, and more recent than your training data:
- https://docs.pug.sh/sdks/node/index.md - SDK reference
- 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.
```

## Initialize

Construct a `Pug` instance **once** at boot with your private key - every other
call needs it first. Private keys live under **Settings > API Keys**, but one is
only shown when you [create it](/get-started/authentication) - if you don't have
yours, issue a new one.

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

// Instance-based - construct once at boot, reuse across requests.
const pug = new Pug({
  apiKey: process.env.PUG_PRIVATE_KEY!, // prv_... - server-side only, never ship to a client
})
```

`new Pug(options)` returns an instance immediately. The constructor **throws**
if `apiKey` is missing or doesn't start with `prv_` - the server SDK refuses a
public key up front rather than failing later on the first read.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `apiKey` | `string` | **required** | Private key (`prv_...`), sent as `x-api-key`. Server-side only. |
| `endpoint` | `string` | Pug Cloud | Pug server origin. Defaults to `https://api.pugs.dev` - set it only when self-hosting. |
| `batch` | `Partial<BatchConfig>` | `maxSize 100`, `maxWaitMs 5000`, `maxQueueSize 10000` | Ingestion buffer size and flush timing. |
| `onError` | `OnError` | no-op | Dead-letter hook for buffered `track()` events that couldn't be delivered - permanent send failure, queue overflow, or still-undelivered at `close()`. (`identify()` failures are logged, not routed here.) |

Call `await pug.close()` on graceful shutdown so buffered events flush -
anything still undeliverable is reported through `onError`. The Node SDK has no
`destroy()`, `reset()`, or `rotate()`: a server has no ambient user or session
to reset.

## Track events

Events are the foundation of every analytic in Pug. After the `distinctId`, the
next argument is the event **name** (the `kind`): custom names work without a
schema, while [well-known events](/reference/well-known-events) add typed
properties and richer dashboards. Names starting with `pug.` are reserved.

```ts
// distinctId (who the event is for) comes first - a server has no ambient user.
pug.track('user-123', 'order.completed', { amount: 49, currency: 'USD' })

// Well-known events get typed, validated properties.
pug.track('user-123', 'purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' })
```

`pug.track(distinctId, kind, props?, options?)` puts the **`distinctId` first** -
a server has no ambient user, so you name who each event is for (your external
ID, or an `anon-...` ID forwarded from the client). It **never throws** and is
non-blocking: events are validated, batched, and flushed in the background.

There is no `{ immediate }` on the server - flush explicitly instead:

```ts
await pug.flush()   // send what's buffered right now
await pug.close()   // drain on graceful shutdown so nothing buffered is lost
```

`flush()` resolves when that batch attempt settles - not a guarantee the queue
is empty, since a transient failure re-queues for a later flush. Use `close()`
to drain fully on shutdown.

`track(distinctId, kind, props?, options?)` options:

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

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

A [well-known event](/reference/well-known-events)'s props are checked against
its schema before the event is queued. A prop that fails - a wrong type, or a
rule such as `currency` needing three uppercase letters - drops the **whole
event**, and the log names the field (`quantity: expected int32, got string`).
Keys outside the schema pass through as custom properties. Two field types take
a particular shape:

- **int64** fields such as `sizeBytes` take a safe integer or a `bigint`. A
  string is rejected rather than parsed.
- **Duration** fields such as `position` take `{ seconds, nanos }`, sent as a
  string like `"90.5s"`.

The `distinctId` must not start with `cookieless-`. The server reserves that
prefix and rejects a whole batch over one such event, so `track()` logs and
drops it before it can take other users' events down with it.

### Visitor location

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

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

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

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

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

## Identify and sessions

Identity turns anonymous events into actionable profiles. 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.

```ts
// On the first identify, pass the SDK's anonymousId to merge prior anonymous activity.
await pug.identify('user-123', { email: 'user@example.com', plan: 'pro' }, {
  anonymousId: 'anon-abc123',
})
```

`pug.identify(externalId, traits?, options?)` returns a promise and **never
throws** - failures are logged. `traits` must be a plain object: a string,
array, `Date` or `Map` is logged and nothing is sent, as is an `externalId`
starting with the reserved `cookieless-` prefix. 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.

### Sessions

The SDK uses a single session ID for the instance's lifetime - override it per
call with `track(..., { sessionId })`, since sessions are a client concept. For
the anonymous->identified model, see [Core concepts](/get-started/concepts).

## Auto-tracking

There is no client-side auto-tracking on the server - you send every event
explicitly with `pug.track(distinctId, ...)`. The server doesn't fill in geo or
user agent either: both read the connection, which on a private key is your
backend, so pass the visitor's [`location`](#visitor-location) yourself. Server
events are also never tagged `$bot`, so [reads](#server-side-reads) never leave
them out as bot traffic.

## Consent and privacy

The Node SDK has no consent gate and no redaction hooks, so both are yours:
respect the visitor's consent choice before you call `track()`, and keep PII
out of the properties you pass.

## Server-side reads

The 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` - see the
[error model](/api#error-model)); they run in your control flow, so you own
timeout and retry. Ingestion (`track` / `identify`) still never throws.

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

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

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

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

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

Reads leave out traffic tagged as a bot at ingest: web-SDK events from crawler
user agents and datacenter networks. Events from this SDK are never tagged. Set
`includeBots: true` (**0.0.7+**) to count bot traffic - `profiles.get` and
`getByExternalId` take it in their second argument, and `profiles.list`,
`insights.query` (on its `spec`) and the activity `feed`, `eventExplorer`,
`heatmap`, `profileSessions` and `profileStats` take it in the request:

```ts
const profile = await pug.profiles.getByExternalId('user-123', { includeBots: true })
const { sessions } = await pug.activity.profileSessions({ distinctId: 'user-123', includeBots: true })
```

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

## Upgrading

### v0.0.7

Nothing here breaks compilation. The changes are in what reaches Pug: input
that 0.0.6 altered and sent anyway is now dropped with a `[Pug SDK]` error, so
check your logs after upgrading.

- **A well-known event with a wrong-typed prop is dropped.** 0.0.6 sent it
  without that field, under an "unsupported scalar type" warning. Now nothing is
  sent, and the error names the field and the type it expected - see
  [Track events](#track-events).
- **`identify()` needs plain-object traits.** A string, array, `Date` or `Map`
  used to go out as empty traits (`{}`). Now the call logs and sends nothing.
- **int64 and Duration props are sent.** A number in an int64 field such as
  `sizeBytes` used to be skipped with a warning. A Duration such as `position`
  was skipped too, or dropped the whole event when `seconds` was a number. Both
  now go out, a Duration as `"90.5s"`.
- **A `distinctId` starting with `cookieless-` is dropped**, since the server
  rejects the whole batch over one.
- **Server-side drops are logged.** When the server refuses events from a batch
  it reports them in `dropped` rather than failing the request, and the SDK now
  logs a warning with the reasons.
- **`includeBots` is new on reads.** The server leaves bot-tagged traffic out of
  reads by default whichever SDK version you run; 0.0.7 is the first that can
  ask for it back. See [Server-side reads](#server-side-reads).

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