SDK reference · @ravenkeep/node

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.

The documentation is in English only. Ravenkeep em português

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.

TerminalShell
npm install @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.tsTypeScript
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.tsTypeScript
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.tsTypeScript
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.tsTypeScript
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.

OptionDefaultMeaning
secretKeynoneThe project secret key, rk_secret_.... Without it every call reports missing_secret_key; a publishable key reports secret_key_required and is never sent.
hosthttps://api.ravenkeep.ioAPI origin. Change it only for a self-hosted Ravenkeep.
identitySecretnoneThe project identity secret. Enables widgetUserHash.
flushAt50Queued events that trigger a send, at most 500 (one batch).
flushIntervalMs2000Longest time an event waits in the queue before it is sent.
maxQueueSize10000Items kept per queue (events, exceptions) while the API cannot be reached. Past it the oldest are dropped and reported as queue_overflow.
maxRetries5Retries of a queued batch, with backoff from 1 to 30 seconds. Request methods retry at most twice. 0 turns retries off.
timeoutMs10000Time limit of each request.
environmentnoneAttached to exceptions; the API stores production when it is missing. Crash groups are separate per environment.
releasenoneYour app version or commit, attached to exceptions. Resolutions and regressions compare releases.
captureUncaughtfalseOpt in to reporting uncaughtException and unhandledRejection (see Capture exceptions).
fetchglobal fetchA fetch implementation, for tests, proxies or custom agents.
onErrorone console.warn per codeReceives 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.tsTypeScript
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.tsTypeScript
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 }

FieldCaptured fromNotes
utmSourceutm_sourceWhere the visit came from, such as google or newsletter. Groups the Attribution report.
utmMediumutm_mediumChannel, such as cpc or email.
utmCampaignutm_campaignCampaign name. The second level of the Attribution report.
utmTermutm_termPaid search keyword.
utmContentutm_contentAd or link variant.
gclidgclidGoogle Ads click id.
fbclidfbclidMeta click id.
msclkidmsclkidMicrosoft Ads click id.
referrerdocument.referrerThe referring page of the first visit. A referrer on your own site is internal navigation and is dropped.
landingPagelocation.hrefThe first page the visitor opened, without its #hash.
capturedAtClockISO 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

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.tsTypeScript
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,
})
FieldMeaning
nameUp 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.
userIdYour user id. An unknown id creates the contact. One of userId and anonymousId is required.
anonymousIdFor visitors without an account. Such events have no contact and do not start flows.
propertiesA JSON object up to 32 KiB. Keep values small and categorical (plan, source, size buckets) so the Metrics breakdown can group by them.
timestampWhen 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.
messageIdIdempotency key, a random UUID by default. The API drops a repeated id for 7 days, so retries never count twice.
sessionIdSince 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

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.tsTypeScript
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 }

FieldMeaning
amountA 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).
currencyISO 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.
kindpayment (default) adds to the LTV; refund subtracts. The LTV may go negative when you refund a payment made before the integration.
referenceYour 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.
timestampWhen 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.

server/checkout.tsTypeScript
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)
})
OptionMeaning
userId, anonymousIdWho was hit. With a userId the console shows the affected customers, their share of active users and the revenue at risk.
tagsUp to 50 searchable key and value pairs (keys up to 64 characters, values up to 200). Numbers and booleans become strings.
contextExtra 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.
levelerror (default), warning or fatal.
fingerprintAn 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.tsTypeScript
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

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.tsTypeScript
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

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.tsTypeScript
// 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.

  • 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.

  • trackvoid

    Queues 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.

  • captureExceptionvoid

    Queues an exception with stack frames, cause chain, tags, context, level and fingerprint.

  • widgetUserHashstring

    HMAC 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

ExportWhat it is
parseAttributionparseAttribution(url, referrer?, { siteHost?, capturedAt? }) returns an Attribution object, empty when the URL carries none. Never throws.
isEmptyAttributionWhether an attribution has no source field at all.
RavenkeepErrorThe error class of every failure: code, status, message, issues, details.
SDK_VERSIONThe package version, also sent as X-Ravenkeep-SDK: node/<version>.
RAVENKEEP_API_URLThe default host, https://api.ravenkeep.io.
TypesRavenkeepOptions, IdentifyInput, SignupInput, TrackInput, RevenueInput, CaptureOptions, Attribution, RavenkeepResult and the response types.

HTTP requests behind each call

MethodRequest to https://api.ravenkeep.io
pingGET /v1/ping
identifyPOST /v1/identify
signupPOST /v1/signup
trackPOST /v1/events with up to 500 events
revenuePOST /v1/revenue
captureExceptionPOST /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.tsTypeScript
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) })
CodeStatusMeaning
missing_secret_key0The client was created without a key. Nothing is sent; check the environment variable.
secret_key_required0A publishable key was passed. The SDK refuses it before sending anything.
invalid_api_key401The key is unknown or was rotated. Rotation invalidates the old key immediately.
validation_failed0 or 422Invalid input, refused before sending, or by the API. issues lists every problem with its path.
currency_mismatch422Revenue 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_conflict409A revenue reference already recorded for another user, amount or currency. Nothing changed.
payload_too_large0 or 413A 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_limited429Past 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_busy503The API is saturated. Retried like any 5xx answer.
invalid_request400The body was not a JSON object. The SDK never sends one; a proxy may have changed it.
internal_error0 or 500An unexpected failure inside the SDK or the API.
http_<status>the statusAn error answer without the Ravenkeep envelope, such as a gateway page.
network_error0The request never reached Ravenkeep. Retried; revenue() only with a reference.
timeout0No complete answer within timeoutMs. Retried like a network error.
invalid_response2xxThe API answered without a JSON body (a proxy or gateway in between). Not retried.
missing_identity_secret0widgetUserHash was called without the identitySecret option; it returned an empty string.
items_rejected200The 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_overflow0The oldest queued items were dropped past maxQueueSize (details.dropped).
client_closed0track or captureException after shutdown().
flush_timeout0shutdown() 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