# Ravenkeep Node SDK reference

**The Node SDK.** `@ravenkeep/node` runs on your backend with the project secret key. It reports signups with their first-touch attribution, product events, revenue and exceptions, and signs the user ids your browser widget identifies.

- **Node 18+**: Built on the global fetch, with no runtime dependencies
- **rk_secret_**: Secret key, server only, sent in the apikey header
- **{ data, error }**: Requests resolve to an envelope and never throw

## Installation

The package is public on npm and has no runtime dependencies. It ships ESM, CommonJS and type declarations.

**npm**

```bash
npm install @ravenkeep/node
```

**pnpm**

```bash
pnpm add @ravenkeep/node
```

**yarn**

```bash
yarn add @ravenkeep/node
```

The first releases are 0.x versions (the package is 0.2.0 today), and before 1.0 a minor release may change the API, so pin the version you test with. This reference follows the SDK as it is built: what came with a later version says so.

## Quickstart

Create one client per process with the secret key of your project (Settings, then Projects, then Keys, in the console). Read the key from the environment; it must never reach a browser bundle.

**server/ravenkeep.ts**

```ts
import { Ravenkeep } from '@ravenkeep/node'

// One client per process. The secret key never leaves your server:
// RAVENKEEP_SECRET_KEY=rk_secret_lumen_xxxxxxxxxxxx
export const ravenkeep = new Ravenkeep({
  secretKey: process.env.RAVENKEEP_SECRET_KEY,
  release: process.env.APP_VERSION,
})
```

Check the key once, for example in a health check or a deploy script. `ping()` resolves to the project the key belongs to.

**scripts/check-ravenkeep.ts**

```ts
const { data, error } = await ravenkeep.ping()
if (error) console.error('Ravenkeep key problem:', error.code)
else console.log(`Connected to ${data.projectName}`)
```

_{ projectId, projectName, keyKind: "secret" }_

Report the signup from the handler that creates the user, then track what the user does. `signup` resolves to `{ data, error }`; `track` queues the event and returns at once.

**server/signup.ts**

```ts
import { ravenkeep } from './ravenkeep'

// In your signup handler, after the user exists in your database.
const { error } = await ravenkeep.signup({
  userId: user.id,
  email: user.email,
  name: user.name,
  attribution: body.attribution, // getAttribution() from the browser SDK
})

ravenkeep.track('file.uploaded', { userId: user.id, properties: { sizeMb: 12 } })
```

Before a long-running process exits, flush the queue so nothing is lost. Serverless functions call `flush()` before they return (see Flush, shutdown and serverless).

**server/shutdown.ts**

```ts
process.on('SIGTERM', async () => {
  await ravenkeep.shutdown()
  process.exit(0)
})
```

## Configuration

Every option except `secretKey` is optional. Construction never throws: a missing key, or a publishable key passed by mistake, surfaces as an error on the first call, so builds that import the module without environment variables keep working.

| Option | Default | Meaning |
| --- | --- | --- |
| `secretKey` | none | The project secret key, `rk_secret_...`. Without it every call reports `missing_secret_key`; a publishable key reports `secret_key_required` and is never sent. |
| `host` | `https://api.ravenkeep.io` | API origin. Change it only for a self-hosted Ravenkeep. |
| `identitySecret` | none | The project identity secret. Enables `widgetUserHash`. |
| `flushAt` | `50` | Queued events that trigger a send, at most 500 (one batch). |
| `flushIntervalMs` | `2000` | Longest time an event waits in the queue before it is sent. |
| `maxQueueSize` | `10000` | Items kept per queue (events, exceptions) while the API cannot be reached. Past it the oldest are dropped and reported as `queue_overflow`. |
| `maxRetries` | `5` | Retries of a queued batch, with backoff from 1 to 30 seconds. Request methods retry at most twice. `0` turns retries off. |
| `timeoutMs` | `10000` | Time limit of each request. |
| `environment` | none | Attached to exceptions; the API stores `production` when it is missing. Crash groups are separate per environment. |
| `release` | none | Your app version or commit, attached to exceptions. Resolutions and regressions compare releases. |
| `captureUncaught` | `false` | Opt in to reporting `uncaughtException` and `unhandledRejection` (see Capture exceptions). |
| `fetch` | global `fetch` | A fetch implementation, for tests, proxies or custom agents. |
| `onError` | one `console.warn` per code | Receives every failure of the background pipeline as a `RavenkeepError`. It never receives keys. |

## Identify contacts

A contact is one customer of your product (the console lists them under Customers), keyed by your own user id. `identify` creates or updates it with an email, a name, traits and the account (company or workspace) the user belongs to. Call it when something that matters to operations changes: the plan, the company, the seats. Contacts are also created on the fly by `signup`, `track`, `revenue` and `captureException`.

**server/identify.ts**

```ts
const { data, error } = await ravenkeep.identify({
  userId: user.id,
  email: user.email,
  name: user.name,
  traits: { plan: 'pro', seats: 12, locale: 'pt-BR' },
  account: { id: org.id, name: org.name },
})
```

_{ ok: true, contactId, created }_

- Only the fields a call carries are written. Traits merge into the stored ones, key by key; a merge past 64 KiB is refused with `validation_failed` and nothing changes.
- What you set here wins over the browser: an `identify` from the browser SDK only fills a name or an email the contact does not have yet, and never replaces yours. An email set with the secret key is a trusted address, one campaign emails can go to.
- The trait `locale` (`en` or `pt-BR`) sets the language of the campaign emails the contact gets: the unsubscribe footer and the unsubscribe page. Without it they use the default language of the project.
- A call that changes nothing writes nothing, so calling `identify` on every sign-in is cheap.
- An identified contact counts as active that day (at most one `$identify` event per contact and hour).
- `timestamp` (a `Date`, an ISO string or epoch milliseconds) dates the call when you replay it later.

## Signups and attribution

`signup` marks the moment a customer signed up and records a `$signup` event. Its attribution is the **first touch**: the UTM parameters, click ids, referrer and landing page of the visit that brought them, captured by the browser SDK before the account existed. Ravenkeep writes it once and never overwrites it, which is what makes CAC and LTV per source reliable months later.

**server/signup.ts**

```ts
import { parseAttribution } from '@ravenkeep/node'

// Preferred: the browser sends getAttribution() with the signup form.
// Without the browser SDK, parse the landing URL your app remembered.
const attribution =
  body.attribution ??
  parseAttribution(session.landingUrl, session.referrer, { siteHost: 'lumen.example' })

const { data, error } = await ravenkeep.signup({
  userId: user.id,
  email: user.email,
  account: { id: org.id, name: org.name },
  traits: { plan: 'free' },
  attribution,
})
if (error) logger.warn({ code: error.code }, 'Ravenkeep signup not recorded') // never block the signup
```

_{ ok: true, contactId, created, signedUp, attributionSet }_

| Field | Captured from | Notes |
| --- | --- | --- |
| `utmSource` | `utm_source` | Where the visit came from, such as `google` or `newsletter`. Groups the Attribution report. |
| `utmMedium` | `utm_medium` | Channel, such as `cpc` or `email`. |
| `utmCampaign` | `utm_campaign` | Campaign name. The second level of the Attribution report. |
| `utmTerm` | `utm_term` | Paid search keyword. |
| `utmContent` | `utm_content` | Ad or link variant. |
| `gclid` | `gclid` | Google Ads click id. |
| `fbclid` | `fbclid` | Meta click id. |
| `msclkid` | `msclkid` | Microsoft Ads click id. |
| `referrer` | `document.referrer` | The referring page of the first visit. A referrer on your own site is internal navigation and is dropped. |
| `landingPage` | `location.href` | The first page the visitor opened, without its `#hash`. |
| `capturedAt` | Clock | ISO timestamp of the first touch. Set only when something was captured. |

- Signups are idempotent per `userId`: a repeat answers `signedUp: false` and never counts twice.
- Attribution is written only by the first signup, and only if the contact has none: `attributionSet` says whether this call wrote it. A contact that signed up without attribution never gets one later.
- `signedUpAt` dates a signup you import; a future value becomes now.
- `attribution` may be `null` or empty: the signup is recorded without a source. Values that are not web URLs (a `javascript:` landing page, free text as referrer) are dropped, never refused.
- Operators enter acquisition spend per source in the console (Metrics, then Attribution); CAC divides that spend by the paying customers the source brought.

`parseAttribution(url, referrer?, options?)` reads the UTM parameters and click ids of a landing URL. Pass `siteHost` (your domain, or a list) so a referrer from your own site, `www.` and other subdomains included, is treated as internal navigation.

[How attribution travels from your site to your app](https://ravenkeep.io/docs#attribution)

## Track events

Events are the product moments your operations team cares about: a ticket created, a file uploaded, members invited. They feed the Metrics screens, start campaign flows and count as campaign goals. `track` validates the event, queues it and returns at once.

**server/events.ts**

```ts
ravenkeep.track('ticket.created', { userId: user.id, properties: { priority: 'high' } })
ravenkeep.track('members.invited', { userId: user.id, properties: { count: 3, role: 'editor' } })

// Visitors without an account yet
ravenkeep.track('pricing.viewed', { anonymousId: req.cookies.anon_id })

// Imports and backfills: your own idempotency key and occurrence time
ravenkeep.track('document.created', {
  userId: doc.ownerId,
  messageId: `doc-${doc.id}-created`,
  timestamp: doc.createdAt,
})
```

| Field | Meaning |
| --- | --- |
| `name` | Up to 100 characters. Names starting with `$` are reserved for Ravenkeep (`$signup`, `$revenue`, `$identify`, `$pageview`, `$screen`, `$pageleave`, `$screenleave`; a `$screen` names its screen in `properties.screen`, at most 200 characters, and ranks in Metrics, Pages & screens; a `$screenleave` reports the time spent on one, `{ screen, engaged_ms }`, as Time on pages and screens describes). A project keeps at most 1,000 distinct names, so ids belong in properties. |
| `userId` | Your user id. An unknown id creates the contact. One of `userId` and `anonymousId` is required. |
| `anonymousId` | For visitors without an account. Such events have no contact and do not start flows. |
| `properties` | A JSON object up to 32 KiB. Keep values small and categorical (`plan`, `source`, size buckets) so the Metrics breakdown can group by them. |
| `timestamp` | When it happened (`Date`, ISO string or epoch milliseconds); now by default. The API refuses events older than 366 days or more than a day ahead (`timestamp_out_of_range`), and events older than the plan’s history. |
| `messageId` | Idempotency key, a random UUID by default. The API drops a repeated id for 7 days, so retries never count twice. |
| `sessionId` | Since 0.2.0. The visit the event belongs to: 8 to 64 letters, digits, `_` or `-`. Pass the browser's `ravenkeep.getSessionId()` (sent by your frontend) to count a server event in that visit. Without one (`null`, empty or blank count as none), the event joins the person's other events without an id by the 30-minute gap, which make a visit only with a page or screen view among them. A malformed id drops the event (`validation_failed`). |

### Batching and delivery

- Events leave in batches when `flushAt` (50) are waiting or `flushIntervalMs` (2 seconds) after the first one, at most 500 events and under 1 MiB per request, one request in flight at a time.
- Network errors, timeouts, 408, 429 and 5xx answers are retried with exponential backoff and jitter (1 to 30 seconds, `maxRetries` times), honoring `Retry-After`. A batch the API finds too large is split; other 4xx answers are not retried.
- While the API cannot be reached, events wait in memory, up to `maxQueueSize` (10,000) per queue; past it the oldest are dropped and reported once per burst as `queue_overflow`.
- An invalid event is dropped before it is queued and reported to `onError` as `validation_failed`. Events the API refuses are reported as `items_rejected`, with their index and code in `details.rejected`.

[Naming events for Metrics, flows and goals](https://ravenkeep.io/docs#events)

## Revenue

Report payments and refunds as they settle, for example from your billing webhooks. A contact's lifetime value (LTV) is the sum of its payments minus its refunds, and it feeds the LTV and CAC report per acquisition source and the revenue at risk of a crash.

**server/billing-webhook.ts**

```ts
if (event.type === 'invoice.paid') {
  const invoice = event.data.object
  await ravenkeep.revenue({
    userId: invoice.metadata.userId,
    amount: invoice.amount_paid, // 4900 = USD 49.00
    currency: invoice.currency, // 'usd' is read as 'USD'
    reference: invoice.id, // idempotency key: always pass one
  })
}

if (event.type === 'charge.refunded') {
  const charge = event.data.object
  await ravenkeep.revenue({
    userId: charge.metadata.userId,
    amount: charge.amount_refunded,
    currency: charge.currency,
    kind: 'refund',
    reference: charge.id,
  })
}
```

_{ ok: true, contactId, duplicate, ltvMinor }_

| Field | Meaning |
| --- | --- |
| `amount` | A non-negative integer in the **minor units** of the currency, up to 10^12: cents for USD (`4900` is 49.00), whole yen for JPY (`4900` is 4,900), thousandths for KWD (`4900` is 4.900). |
| `currency` | ISO 4217 code, case-insensitive. It must equal the project currency (`USD` by default), or the call fails with `currency_mismatch`. Set the currency when you create the project, or later in Settings, then Projects, then General; it locks once the project has revenue or spend. |
| `kind` | `payment` (default) adds to the LTV; `refund` subtracts. The LTV may go negative when you refund a payment made before the integration. |
| `reference` | Your invoice, charge or refund id. Unique per project and `kind`: a repeat with the same user, amount and currency changes nothing and answers `duplicate: true`; with another user, amount or currency it fails with `reference_conflict`. A refund may reuse its payment's reference. |
| `timestamp` | When the money moved; now by default. |

> **Always pass a reference**
>
> With `reference`, timeouts, network errors, 429 and 5xx answers are retried automatically, because the API deduplicates the retry. Without it `revenue()` is never retried: a request whose response was lost may already be recorded, and a blind retry would count the payment twice. The first failure is returned as is, and retrying it is your decision.

A `$revenue` event is recorded with every new payment or refund. It does not count as activity, since a renewal charges customers who may be away.

## Capture exceptions

Report handled errors with `captureException`. It describes the error, queues it and returns at once; exceptions leave as soon as no other request is in flight. Ravenkeep fingerprints each exception from its type and its top in-app frames, so the same bug on a different line number still lands in the same crash group.

**server/checkout.ts**

```ts
try {
  await chargeCard(order)
} catch (error) {
  ravenkeep.captureException(error, {
    userId: order.userId,
    tags: { provider: 'stripe', retry: false },
    context: { orderId: order.id },
    level: 'error',
  })
  throw error
}

// Express error handler, after your routes
app.use((error, req, res, next) => {
  ravenkeep.captureException(error, { userId: req.user?.id, tags: { route: req.route?.path ?? req.path } })
  next(error)
})
```

| Option | Meaning |
| --- | --- |
| `userId`, `anonymousId` | Who was hit. With a `userId` the console shows the affected customers, their share of active users and the revenue at risk. |
| `tags` | Up to 50 searchable key and value pairs (keys up to 64 characters, values up to 200). Numbers and booleans become strings. |
| `context` | Extra data stored with the occurrence, up to 64 KiB of JSON together with the runtime context. Past it your context is left out and the occurrence is marked `contextTruncated`. |
| `level` | `error` (default), `warning` or `fatal`. |
| `fingerprint` | An array of strings (up to 20) that replaces the automatic grouping: occurrences with the same parts form one group. |

- Each occurrence carries the exception type (a subclass name such as `NotFoundError`, even when `name` was not set), the message, the stack (up to 256 KiB) and up to 250 parsed frames.
- Your code is marked in-app relative to the working directory, and its module paths are relative to it, so a release deployed to another directory groups the same way. Dependencies and Node internals are not in-app.
- The `error.cause` chain (up to 5 causes, each with its type, message and a short stack) and the inner errors of an `AggregateError` (up to 10) travel in the context, with the runtime and the OS.
- Anything thrown is accepted: a string or an object becomes an `Error` occurrence with a readable message.
- Set `release` and `environment` on the client: groups are separate per environment, and a resolved group comes back as a regression when a later release reports it.

### Uncaught exceptions (opt in)

`captureUncaught` is off by default. With `captureUncaught: true` the client reports uncaught exceptions and unhandled rejections as `fatal`, waits up to 2 seconds for them to be delivered, then keeps Node's own behavior: the process prints the error and exits with code 1. Unhandled rejections follow `--unhandled-rejections` (`throw`, the default, exits; `warn-with-error-code` sets the exit code; `warn` and `none` keep running). If your application has its own `uncaughtException` or `unhandledRejection` listener, the SDK reports the error and leaves the decision to it.

**server/ravenkeep.ts**

```ts
export const ravenkeep = new Ravenkeep({
  secretKey: process.env.RAVENKEEP_SECRET_KEY,
  release: process.env.GIT_SHA,
  environment: process.env.APP_ENV, // 'production', 'staging'...
  captureUncaught: true,
})
```

[How crash grouping, releases and resolutions work](https://ravenkeep.io/docs#crashes)

## Identity verification

The browser widget identifies customers with a publishable key, which anyone can read. To prove a `userId` is really the signed-in user, your backend signs it with the project identity secret and the browser passes the result as `userHash`. When the project requires verification (the default), widget sessions without a valid hash are refused.

**server/widget.ts**

```ts
const ravenkeep = new Ravenkeep({
  secretKey: process.env.RAVENKEEP_SECRET_KEY,
  identitySecret: process.env.RAVENKEEP_IDENTITY_SECRET,
})

app.get('/api/me', (req, res) => {
  res.json({ id: req.user.id, email: req.user.email, ravenkeepUserHash: ravenkeep.widgetUserHash(req.user.id) })
})

// In the browser: ravenkeep.identify({ userId: me.id, userHash: me.ravenkeepUserHash, email: me.email })
```

_The hash is hex(HMAC-SHA256(identitySecret, userId)). Owners and admins find the identity secret in Settings, then Projects, then Identity verification._

- Without `identitySecret`, `widgetUserHash` returns an empty string and reports `missing_identity_secret` to `onError`.
- The identity secret is not the secret key: keep both on your servers, and never send either to the browser.
- Rotating the identity secret invalidates every hash at once; widget sessions already open keep working until they expire (12 hours).

[Identify users in the browser](https://ravenkeep.io/docs/browser#identify)

## Flush, shutdown and serverless

The SDK's timers never keep a process alive on their own. Short-lived environments must flush before they finish, or queued events are lost when the runtime freezes.

**api/upload.ts**

```ts
// AWS Lambda, Vercel or Netlify functions
export const handler = async (event) => {
  ravenkeep.track('file.uploaded', { userId: event.userId })
  await ravenkeep.flush() // resolves when delivered or dropped, never rejects
  return { statusCode: 200 }
}
```

- `flush(timeoutMs?)` sends everything queued so far and resolves once it was delivered (or dropped after its retries), or after `timeoutMs`. It never rejects.
- `shutdown(timeoutMs = 5000)` flushes, stops the timers and removes the process listeners. Call it once, on `SIGTERM` or when your server closes. Later `track` and `captureException` calls are ignored and reported as `client_closed`.
- If `shutdown()` runs out of time, what is left is reported as `flush_timeout` with the count in `details.pending`.
- A script that ends without `shutdown()` is flushed on `beforeExit`, for at most 2 seconds.

## API reference

`new Ravenkeep(options)` returns the client and never throws. Request methods resolve to `{ data, error }` (exactly one is set) and never throw; queued methods return `void`.

| Method | Returns | Description |
| --- | --- | --- |
| `ping` | `Promise<{ data, error }>` | Checks the key: { projectId, projectName, keyKind }. |
| `identify` | `Promise<{ data, error }>` | Creates or updates a contact with email, name, traits and account. |
| `signup` | `Promise<{ data, error }>` | Marks the signup, keeps the first-touch attribution and records $signup. Idempotent per userId. |
| `track` | `void` | Queues a product event: batched, retried, deduplicated by messageId. |
| `revenue` | `Promise<{ data, error }>` | Records a payment or refund in minor units and adjusts the contact LTV. Retried only with a reference. |
| `captureException` | `void` | Queues an exception with stack frames, cause chain, tags, context, level and fingerprint. |
| `widgetUserHash` | `string` | HMAC of a user id with the identity secret, for the browser identify call. |
| `flush` | `Promise<void>` | Sends everything queued now; optional timeout. Never rejects. |
| `shutdown` | `Promise<void>` | Flushes (5 seconds by default), stops the timers and removes the process listeners. |

### Other exports

| Export | What it is |
| --- | --- |
| `parseAttribution` | `parseAttribution(url, referrer?, { siteHost?, capturedAt? })` returns an `Attribution` object, empty when the URL carries none. Never throws. |
| `isEmptyAttribution` | Whether an attribution has no source field at all. |
| `RavenkeepError` | The error class of every failure: `code`, `status`, `message`, `issues`, `details`. |
| `SDK_VERSION` | The package version, also sent as `X-Ravenkeep-SDK: node/<version>`. |
| `RAVENKEEP_API_URL` | The default host, `https://api.ravenkeep.io`. |
| Types | `RavenkeepOptions`, `IdentifyInput`, `SignupInput`, `TrackInput`, `RevenueInput`, `CaptureOptions`, `Attribution`, `RavenkeepResult` and the response types. |

### HTTP requests behind each call

| Method | Request to https://api.ravenkeep.io |
| --- | --- |
| `ping` | `GET /v1/ping` |
| `identify` | `POST /v1/identify` |
| `signup` | `POST /v1/signup` |
| `track` | `POST /v1/events` with up to 500 events |
| `revenue` | `POST /v1/revenue` |
| `captureException` | `POST /v1/crashes` with up to 50 crashes |

Every request carries the key in the `apikey` header and the SDK version in `X-Ravenkeep-SDK`. The key never travels in a URL.

## Errors and troubleshooting

Request methods resolve to `{ data: null, error }` on failure; failures of queued work go to `onError`. `error` is a `RavenkeepError` with a stable `code`, the HTTP `status` (0 when the request never reached Ravenkeep), `issues` for validation failures (each with its `path`) and `details`. The API answers every failure with `{ "error": { "code", "message" } }`, and messages never contain your keys.

**server/signup.ts**

```ts
const { data, error } = await ravenkeep.signup({ userId })
if (error) {
  logger.warn({ code: error.code, status: error.status, issues: error.issues }, error.message)
}

// Background failures (queued events and exceptions)
new Ravenkeep({ secretKey, onError: (error) => logger.warn({ code: error.code, details: error.details }, error.message) })
```

| Code | Status | Meaning |
| --- | --- | --- |
| `missing_secret_key` | 0 | The client was created without a key. Nothing is sent; check the environment variable. |
| `secret_key_required` | 0 | A publishable key was passed. The SDK refuses it before sending anything. |
| `invalid_api_key` | 401 | The key is unknown or was rotated. Rotation invalidates the old key immediately. |
| `validation_failed` | 0 or 422 | Invalid input, refused before sending, or by the API. `issues` lists every problem with its path. |
| `currency_mismatch` | 422 | Revenue in a currency other than the project currency. While the project has no revenue or spend, change its currency in Settings, then Projects, then General. |
| `reference_conflict` | 409 | A revenue reference already recorded for another user, amount or currency. Nothing changed. |
| `payload_too_large` | 0 or 413 | A batch the API found too large is split and sent again in halves. A single item over 1 MiB is dropped before it is queued. |
| `rate_limited` | 429 | Past the project's budget for that route family (events 200 requests per second; signups, identify and revenue together 100; crashes 100; bursts of twice that), or past its crash budget. Queued batches wait for `Retry-After`; request methods retry when it is short. |
| `service_busy` | 503 | The API is saturated. Retried like any 5xx answer. |
| `invalid_request` | 400 | The body was not a JSON object. The SDK never sends one; a proxy may have changed it. |
| `internal_error` | 0 or 500 | An unexpected failure inside the SDK or the API. |
| `http_<status>` | the status | An error answer without the Ravenkeep envelope, such as a gateway page. |
| `network_error` | 0 | The request never reached Ravenkeep. Retried; `revenue()` only with a reference. |
| `timeout` | 0 | No complete answer within `timeoutMs`. Retried like a network error. |
| `invalid_response` | 2xx | The API answered without a JSON body (a proxy or gateway in between). Not retried. |
| `missing_identity_secret` | 0 | `widgetUserHash` was called without the `identitySecret` option; it returned an empty string. |
| `items_rejected` | 200 | The API took the batch but refused some items: `details.rejected` lists their index and code (`reserved_name`, `timestamp_out_of_range`, `event_name_limit`, `validation_failed`, `plan_limit_reached`, `leave_limit_reached`). They are not retried. |
| `queue_overflow` | 0 | The oldest queued items were dropped past `maxQueueSize` (`details.dropped`). |
| `client_closed` | 0 | `track` or `captureException` after `shutdown()`. |
| `flush_timeout` | 0 | `shutdown()` ran out of time with items still queued (`details.pending`). |

- **Events missing after a deploy.** The process exited before the queue was flushed: call `shutdown()` on exit, or `flush()` at the end of a serverless function.
- **Crashes split into many groups.** Minified or generated code hides the in-app frames; pass a `fingerprint`, or deploy with readable function names.
- **Attribution is empty.** The browser SDK was not on the landing page, the signup did not forward `getAttribution()`, or the contact had already signed up.
- **Revenue counted twice.** The call had no `reference`. Pass your invoice or charge id and repeats become `duplicate: true`.
- **The widget refuses the user.** The hash was computed with an old identity secret, or for another user id.

## Compatibility

- Node.js 18 or newer (global `fetch`). Bun and Deno run it through their Node.js compatibility; the test suite runs on Node.js.
- ESM and CommonJS entry points with TypeScript declarations, and no runtime dependencies.
- Semantic versioning: patches fix, minors add, majors change a contract (the `{ data, error }` shape, the never-throw rule, event semantics, a removed option or runtime). Before 1.0 a minor release may change the API. Releases are tagged `sdk-node-vX.Y.Z`.
- The secret key and the identity secret are kept in private fields, never logged and never sent in a URL.

[Continue with the browser SDK](https://ravenkeep.io/docs/browser)

---

Markdown rendering of https://ravenkeep.io/docs/node. The other docs pages in Markdown: https://ravenkeep.io/docs/index.md, https://ravenkeep.io/docs/browser.md, https://ravenkeep.io/docs/android.md, https://ravenkeep.io/docs/ios.md, https://ravenkeep.io/docs/import.md.
