Installation
The package is public on npm and has no runtime dependencies. It ships ESM, CommonJS and type declarations.
npm install @ravenkeep/nodepnpm add @ravenkeep/nodeyarn add @ravenkeep/nodeThe 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.
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.
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.
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).
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.
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_failedand nothing changes. - What you set here wins over the browser: an
identifyfrom 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(enorpt-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
identifyon every sign-in is cheap. - An identified contact counts as active that day (at most one
$identifyevent per contact and hour). timestamp(aDate, 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.
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 answerssignedUp: falseand never counts twice. - Attribution is written only by the first signup, and only if the contact has none:
attributionSetsays whether this call wrote it. A contact that signed up without attribution never gets one later. signedUpAtdates a signup you import; a future value becomes now.attributionmay benullor empty: the signup is recorded without a source. Values that are not web URLs (ajavascript: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.
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.
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 orflushIntervalMs(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,
maxRetriestimes), honoringRetry-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 asqueue_overflow. - An invalid event is dropped before it is queued and reported to
onErrorasvalidation_failed. Events the API refuses are reported asitems_rejected, with their index and code indetails.rejected.
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.
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. |
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.
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 whennamewas 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.causechain (up to 5 causes, each with its type, message and a short stack) and the inner errors of anAggregateError(up to 10) travel in the context, with the runtime and the OS. - Anything thrown is accepted: a string or an object becomes an
Erroroccurrence with a readable message. - Set
releaseandenvironmenton 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.
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,
})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.
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,widgetUserHashreturns an empty string and reportsmissing_identity_secrettoonError. - 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).
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.
// 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 aftertimeoutMs. It never rejects.shutdown(timeoutMs = 5000)flushes, stops the timers and removes the process listeners. Call it once, onSIGTERMor when your server closes. LatertrackandcaptureExceptioncalls are ignored and reported asclient_closed.- If
shutdown()runs out of time, what is left is reported asflush_timeoutwith the count indetails.pending. - A script that ends without
shutdown()is flushed onbeforeExit, 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.
pingPromise<{ data, error }>Checks the key: { projectId, projectName, keyKind }.
identifyPromise<{ data, error }>Creates or updates a contact with email, name, traits and account.
signupPromise<{ data, error }>Marks the signup, keeps the first-touch attribution and records $signup. Idempotent per userId.
trackvoidQueues a product event: batched, retried, deduplicated by messageId.
revenuePromise<{ data, error }>Records a payment or refund in minor units and adjusts the contact LTV. Retried only with a reference.
captureExceptionvoidQueues an exception with stack frames, cause chain, tags, context, level and fingerprint.
widgetUserHashstringHMAC of a user id with the identity secret, for the browser identify call.
flushPromise<void>Sends everything queued now; optional timeout. Never rejects.
shutdownPromise<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.
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, orflush()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 becomeduplicate: 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 taggedsdk-node-vX.Y.Z. - The secret key and the identity secret are kept in private fields, never logged and never sent in a URL.