# Node.js SDK

> Send events and confirm contact consent from a Node.js backend with @whisperr/node, including Express middleware and billing events.

`@whisperr/node` sends events and identify calls from a Node.js backend. Use it for facts only your server knows, such as payment failures, cancellations and plan changes, and to confirm contact consent with the server key.

## Install

```bash
npm install @whisperr/node
```

The package is [`@whisperr/node`](https://www.npmjs.com/package/@whisperr/node) on npm. The current version is 0.2.0. It needs Node.js 18 or later and has no runtime dependencies.

## Quickstart

`whisperr.ts`

```ts
import { createWhisperr } from "@whisperr/node";


// WHISPERR_API_KEY is the server key (wrk_…).
export const whisperr = createWhisperr({ apiKey: process.env.WHISPERR_API_KEY! });


// A server-side event:
whisperr.track("user_8842", "payment_failed", { amount_cents: 4900, reason: "card_declined" });


// Traits and a contact the user agreed to:
whisperr.identify("user_8842", {
  traits: { first_name: "Ada", plan: "pro" },
  channels: [
    { type: "email", address: "ada@example.com", optedIn: true, verified: true },
  ],
});


// Before the process exits:
await whisperr.shutdown();
```

Create the server key as a **Server** key in **Settings → API keys**. Hold one client for the life of the process.

`track(userId, eventType, properties?, context?)` always takes the user id first. Pass the same id your app passes to `identify()`, and backend and frontend events land on [one timeline](/concepts/events/#one-user-one-timeline). Event names must be lowercase `snake_case`; the SDK drops any other name and reports it to `onError` as `dropped`.

## How do I confirm contact consent with the Node SDK?

The Node SDK runs with the server key, so its `identify()` is how you confirm consent. Send `optedIn: true` only when the user agreed to messages on that channel, and `verified: true` only for an email address your app confirmed. Send `optedIn: false` to record an opt-out.

> The Node SDK sends `opted_in: true` for a channel when you leave `optedIn` out. The `email`, `phone` and `pushToken` shortcuts do the same. Send a contact only when you hold consent for it, or pass `optedIn: false`.

See [Contact consent](/concepts/consent/).

## Express

`server.ts`

```ts
import express from "express";
import { createWhisperr } from "@whisperr/node";
import { whisperrExpress } from "@whisperr/node/express";


const app = express();
const whisperr = createWhisperr({ apiKey: process.env.WHISPERR_API_KEY! });


// After your auth middleware:
app.use(whisperrExpress(whisperr));


app.post("/plan/upgrade", (req, res) => {
  req.whisperr.track("plan_upgraded", { plan: "pro" });
  res.sendStatus(200);
});
```

`req.whisperr.track()` and `req.whisperr.identify()` use the request’s user. By default the middleware reads `req.user.id`, `req.user.sub`, `req.auth.userId` or `req.userId`, and only string values. When it finds no user, the calls do nothing. For a numeric id or another location, pass a resolver:

```ts
app.use(whisperrExpress(whisperr, { resolveUser: (req) => req.session.userId?.toString() }));
```

For work with no signed-in request, such as payment webhooks and cron jobs, call `whisperr.track(userId, …)` with the id from your own data.

## How do I set the message language from Node?

Pass the language the user picked in your product as the `app_language` trait. Use a BCP 47 tag such as `de` or `pt-BR`. The Node SDK has no separate method for it and never guesses a language.

```ts
whisperr.identify("user_8842", { traits: { app_language: "pt-BR" } });
```

## What the Node SDK adds to every event

| `context` key | Value                                     |
| ------------- | ----------------------------------------- |
| `sdk_name`    | `whisperr-node`                           |
| `sdk_version` | The SDK version, for example `0.2.0`      |
| `app_version` | The `appVersion` option. Not set: no key. |

A key you pass in the `context` argument of `track()` wins over these.

## How do I send billing events from Node?

When Whisperr has no billing source connected for your app (such as [RevenueCat](/integrations/revenuecat/)), send membership changes from your backend with the reserved names in `BillingEvent`. Never send them from the browser or the app.

```ts
import { BillingEvent, type BillingEventProperties } from "@whisperr/node";


const money: BillingEventProperties = {
  plan_id: "pro_monthly",
  price: 9.99, // major units, not cents
  currency: "USD", // ISO 4217, upper case
  plan_period: "P1M", // ISO 8601 duration
  transaction_id: "txn_123",
  store: "stripe",
};


whisperr.track("user_8842", BillingEvent.membershipStarted, {
  ...money,
  is_first_ever: true,
  access_source: "self_subscription",
  current_period_ends_at: "2026-11-12T10:00:00Z",
});
```

| Constant                 | Event name                  | Event properties                                                                                            |
| ------------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `trialStarted`           | `trial_started`             | `trial_length_days`, `trial_ends_at`, `plan_period`                                                         |
| `trialConverted`         | `trial_converted`           | `plan_period`, `current_period_ends_at`                                                                     |
| `trialAutoRenewDisabled` | `trial_auto_renew_disabled` | `trial_ends_at`, `store_cancel_reason`                                                                      |
| `membershipStarted`      | `membership_started`        | `plan_period`, `is_resubscribe`, `is_first_ever`, `access_source`, `entitlements`, `current_period_ends_at` |
| `renewalSucceeded`       | `renewal_succeeded`         | `paid_periods_count`, `current_period_ends_at`                                                              |
| `autoRenewDisabled`      | `auto_renew_disabled`       | `access_ends_at`, `store_cancel_reason`                                                                     |
| `autoRenewEnabled`       | `auto_renew_enabled`        | —                                                                                                           |
| `membershipEnded`        | `membership_ended`          | `end_reason`, `plan_period`, `paid_periods_count`                                                           |
| `billingIssueStarted`    | `billing_issue_started`     | `has_access`, `grace_ends_at`                                                                               |
| `billingRecovered`       | `billing_recovered`         | —                                                                                                           |
| `refundGranted`          | `refund_granted`            | —                                                                                                           |

Closed value lists:

- `store_cancel_reason`: `unsubscribe`, `billing_error`, `developer_initiated`, `price_increase`, `customer_support`, `unknown`.
- `end_reason`: `voluntary`, `billing`, `price_increase`, `product_unavailable`, `developer_initiated`, `refund`, `other`.
- `access_source`: `self_subscription`, `lifetime`, `family_share`, `promotional`, `sponsored`, `web_other`.

Register these event names for your app; [setup with your AI agent](/agents/) registers the events your app sends. Whisperr stores an event with an unregistered name but does not map it to your app’s events.

## Serverless

In Lambda, Vercel functions and other short-lived runtimes, `await whisperr.flush()` before the handler returns. A frozen runtime cannot send the queue later.

## How does the Node SDK deliver events?

- `track()` and `identify()` add to an in-memory queue and return at once. `await flush()` waits until the queue is sent.
- Events go to `POST /v1/events/batch` and identify calls to `POST /v1/identify`, in order, with the `X-API-Key` header.
- Every event has a `$message_id` that stays the same across retries, so Whisperr removes duplicates.
- `429`, `5xx` and network errors are retried with backoff. A `Retry-After` header on `429` or `503` is honored, up to 60 seconds. After `maxRetries`, the batch stays queued for the next flush.
- On `401` or `403` the SDK stops sending and keeps the batch. Other `4xx` responses drop the batch and report `dropped`.
- The queue is not written to disk. A crash loses unsent events.
- The flush timer does not keep the process alive.

See [Delivery, retries, idempotency](/api/delivery/).

## Options

| Option             | Default                    | Notes                                                                      |
| ------------------ | -------------------------- | -------------------------------------------------------------------------- |
| `apiKey`           | —                          | Server key (`wrk_…`). Required.                                            |
| `baseUrl`          | `https://api.whisperr.net` | Ingestion base URL.                                                        |
| `appVersion`       | —                          | Your release version. Sent as `context.app_version`.                       |
| `flushAt`          | `20`                       | Send when this many events are queued.                                     |
| `flushIntervalMs`  | `10000`                    | Background send interval (ms). `0` turns the timer off.                    |
| `maxQueueSize`     | `10000`                    | Oldest events drop when the queue is full.                                 |
| `maxBatchSize`     | `500`                      | Events per batch request. 500 is the maximum.                              |
| `maxRetries`       | `6`                        | Retries before the batch waits for the next flush.                         |
| `requestTimeoutMs` | `10000`                    | Per-request timeout (ms).                                                  |
| `disabled`         | `false`                    | Captures and sends nothing. Useful in tests.                               |
| `debug`            | `false`                    | Logs warnings to the console.                                              |
| `onError`          | —                          | `(error) => void`. `error.type` is `auth`, `dropped` or `retry_exhausted`. |
| `fetch`            | global `fetch`             | Inject your own for tests or custom runtimes.                              |

## Common questions

### Which API key does @whisperr/node use?

The server key (wrk_…), shown as a Server key in Settings → API keys. Keep it in your backend secrets. It can always opt a contact in.

### Why does track() in the Node SDK need a user id?

A backend has no signed-in session to read it from. Pass the same user id your app passes to identify(), so backend and frontend events land on one timeline.

### Do I lose events if my Node process exits?

The queue lives in memory. Call await whisperr.shutdown() before the process exits, or await whisperr.flush() at the end of a serverless handler.

### What Node.js version does @whisperr/node need?

Node.js 18 or later. The SDK uses the global fetch and has no runtime dependencies.

Source: https://docs.whisperr.net/sdks/node/
