@ravenkeep/node
v0.3.0
Published
Ravenkeep SDK for Node.js backends: signups with UTM attribution, ops events, revenue, identify, crash capture and widget identity verification.
Maintainers
Readme
@ravenkeep/node
The Ravenkeep SDK for Node.js backends. Report signups with their first-touch UTM attribution, identify
customers, track operational events (ticket.created, file.uploaded, document.created,
members.invited…), record revenue, capture exceptions, and sign user ids for the support widget.
- Node.js 18+ (also Bun and Deno with Node compatibility). Zero runtime dependencies.
- ESM and CommonJS, with TypeScript declarations.
- Never throws: request/response methods resolve to
{ data, error }, fire-and-forget methods queue.
Installation
The package is public on npm:
npm install @ravenkeep/node
# pnpm add @ravenkeep/node · yarn add @ravenkeep/node · bun add @ravenkeep/nodeQuickstart
import { Ravenkeep } from '@ravenkeep/node'
export const ravenkeep = new Ravenkeep({
secretKey: process.env.RAVENKEEP_SECRET_KEY!, // rk_secret_… (Project settings → Keys)
release: process.env.GIT_SHA,
environment: process.env.NODE_ENV === 'production' ? 'production' : 'staging',
})
await ravenkeep.signup({ userId: user.id, email: user.email, attribution: req.body.attribution })
ravenkeep.track('ticket.created', { userId: user.id, properties: { priority: 'high' } })Create one client per process and reuse it. The secret key is server-side only: never ship it to a
browser (the browser uses @ravenkeep/browser with the publishable key).
Configuration
| Option | Default | Description |
|---|---|---|
| secretKey | — | The project secret key (rk_secret_…). An empty key never throws; calls report missing_secret_key. |
| host | https://api.ravenkeep.io | API origin (self-hosted installations). |
| identitySecret | — | Project identity secret, enables widgetUserHash. |
| flushAt | 50 | Queued events that trigger a send (max 500). |
| flushIntervalMs | 2000 | Longest time an event waits in the queue. |
| maxQueueSize | 10000 | Items kept per queue while the API is unreachable; the oldest are dropped beyond. |
| maxRetries | 5 | Retries of a queued batch; request/response methods retry at most 2 times. 0 disables retries. |
| timeoutMs | 10000 | Per-request timeout. |
| environment | — | Environment of captured crashes (the API defaults to production). |
| release | — | Release of captured crashes (version or commit). |
| captureUncaught | false | Capture uncaughtException / unhandledRejection (see Crashes). |
| fetch | global fetch | Custom fetch (proxies, tests). |
| onError | warn once per code | Receives every failure of the background pipeline. |
Signups and attribution
signup records the signup once per userId and stores the contact's first-touch attribution
(utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid, msclkid,
referrer, landing page). The first attribution a contact gets is kept forever; later signups never
overwrite it. Calling signup again for the same user is safe.
The browser captures the attribution on the visitor's first page view (@ravenkeep/browser
getAttribution()), your signup form sends it along, and your backend forwards it:
// Browser (your signup form)
await fetch('/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password, attribution: ravenkeepBrowser.getAttribution() }),
})Express:
import express from 'express'
import { ravenkeep } from './ravenkeep'
app.post('/api/signup', express.json(), async (req, res) => {
const user = await users.create({ email: req.body.email, password: req.body.password })
const { error } = await ravenkeep.signup({
userId: user.id,
email: user.email,
name: user.name,
account: { id: user.organizationId, name: user.organizationName },
traits: { plan: 'free' },
attribution: req.body.attribution, // Attribution | null from the browser SDK
})
if (error) req.log.warn({ code: error.code }, 'ravenkeep signup not recorded') // never blocks the signup
res.status(201).json({ id: user.id })
})Fastify:
fastify.post<{ Body: { email: string; attribution?: Attribution } }>('/api/signup', async (request, reply) => {
const user = await users.create(request.body)
await ravenkeep.signup({ userId: user.id, email: user.email, attribution: request.body.attribution })
return reply.code(201).send({ id: user.id })
})Without the browser SDK, build the attribution on the server from the landing URL you kept (for example
in a first-party cookie) and the referrer. Referrers from your own site are internal navigation and are
dropped when you pass siteHost:
import { parseAttribution } from '@ravenkeep/node'
const attribution = parseAttribution(req.cookies.landing_url, req.cookies.landing_referrer, {
siteHost: 'bitstorage.io', // also matches www. and subdomains
})
// { utmSource: 'google', utmMedium: 'cpc', utmCampaign: 'launch', gclid: '…',
// landingPage: 'https://bitstorage.io/pricing?utm_source=google…', referrer: 'https://www.google.com/',
// capturedAt: '2026-10-19T12:00:00.000Z' }parseAttribution(url, referrer?, { siteHost?, capturedAt? }) never throws: values are trimmed and
capped, query keys match case-insensitively, the landing page loses its #hash, and capturedAt is set
only when something was captured. isEmptyAttribution(attribution) tells whether there is any source.
Identify
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 },
})Creates or updates the contact. Call it when profile data that matters to operations changes (plan,
company, seats). Values set here win over the browser: @ravenkeep/browser identify() only fills a name
or an email the contact does not have yet, and an email set with the secret key is a trusted address that
campaign emails can go to (an address sent from a browser without identity verification never is). The trait
locale (en or pt-BR) sets the language of the contact's campaign emails (unsubscribe footer and page);
without it the project's default language is used.
Ops events
track returns immediately: events are queued, sent in batches (up to 500 per request) every
flushIntervalMs or as soon as flushAt are waiting, and retried with backoff. Each event gets a UUID
messageId, so a retry is never counted twice.
ravenkeep.track('ticket.created', { userId, properties: { ticketId: ticket.id, priority: ticket.priority } })
ravenkeep.track('file.uploaded', { userId, properties: { sizeBytes: file.size, mime: file.type } })
ravenkeep.track('document.created', { userId, properties: { template: doc.template } })
ravenkeep.track('members.invited', { userId, properties: { count: invites.length, role: 'editor' } })
// Visitors without an account yet
ravenkeep.track('pricing.viewed', { anonymousId: req.cookies.anon_id })
// Your own idempotency key and occurrence time (imports, backfills)
ravenkeep.track('document.created', { userId, messageId: `doc-${doc.id}-created`, timestamp: doc.createdAt })
// In the browser's visit: the frontend sends ravenkeep.getSessionId() (@ravenkeep/browser 0.2.0+)
ravenkeep.track('report.exported', { userId, sessionId: req.body.sessionId })sessionId (8–64 characters of A–Z a–z 0–9 _ -, 0.2.0 and later) places the event in that visit for Metrics →
Sessions. Without one (null, empty or blank count as none), the API groups the event with the person's other events
that have no id, by the 30-minute gap; such a group counts as a visit only when it has a page or screen view. A
malformed id drops the event (validation_failed).
Names are free-form (≤ 100 characters, no control characters); names starting with $ are reserved.
Properties are a JSON object up to 32 KiB. Invalid events are dropped and reported to onError.
Revenue
Amounts are integers in minor units (the currency's ISO 4217 exponent: cents for USD, yen for JPY) in the
project currency. reference (your invoice or charge id) makes the call idempotent per project and kind,
so webhook retries are safe; a repeated call answers duplicate: true and changes nothing.
Always pass reference. With it, timeouts, network errors, 429 and 5xx are retried automatically
(the API deduplicates the retry). Without it, revenue() is never retried: a request whose response was
lost may already have been recorded, and a blind retry would count the payment twice. The first failure is
returned as is, and retrying it is your decision.
// Stripe webhook
if (event.type === 'invoice.paid') {
const invoice = event.data.object
await ravenkeep.revenue({
userId: invoice.metadata.userId,
amount: invoice.amount_paid, // 4900 = $49.00
currency: invoice.currency, // 'usd' → 'USD'
reference: invoice.id,
})
}
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: `refund_${charge.id}`,
})
}Revenue feeds each contact's LTV and the LTV / CAC report by acquisition source.
Crashes
try {
await billing.charge(user)
} catch (error) {
ravenkeep.captureException(error, {
userId: user.id,
tags: { feature: 'checkout' },
context: { invoiceId: invoice.id },
})
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)
})Each occurrence carries the exception type, message, stack and parsed frames (your code is marked
in-app relative to the working directory, dependencies and Node internals are not), the cause chain,
the runtime and OS, plus your release and environment. Ravenkeep groups occurrences of the same bug
(same exception type and top in-app frames) into one crash group even when line numbers change between
releases. To group differently, pass fingerprint: ['checkout', 'stripe-timeout']. Levels: error
(default), warning, fatal.
captureUncaught: true captures uncaught exceptions and unhandled rejections as fatal, waits up to 2 s
for them to be sent, then keeps Node's behavior: the process prints the error and exits with code 1 (for
rejections, according to --unhandled-rejections). If your application registers its own
uncaughtException / unhandledRejection listener, the SDK captures and leaves the decision to it.
Widget identity verification
The support widget (@ravenkeep/browser) proves who the user is with a hash your backend computes:
const ravenkeep = new Ravenkeep({ secretKey, 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) })
})
// browser: ravenkeep.identify({ userId: me.id, userHash: me.ravenkeepUserHash, email: me.email })widgetUserHash(userId) is hex(HMAC-SHA256(identitySecret, userId)). Without identitySecret it
returns '' and reports missing_identity_secret. Never send the identity secret to the browser.
Serverless and shutdown
Timers never keep your process alive, so short-lived environments must flush before they finish:
// AWS Lambda / Vercel / Netlify functions
export const handler = async event => {
ravenkeep.track('file.uploaded', { userId: event.userId })
await ravenkeep.flush() // resolves when delivered, never rejects
return { statusCode: 200 }
}Long-running servers flush on shutdown:
process.on('SIGTERM', async () => {
await server.close()
await ravenkeep.shutdown() // flushes (≤ 5 s), stops timers, removes process listeners
process.exit(0)
})flush(timeoutMs?) waits for everything queued so far (optionally at most timeoutMs). Scripts that end
naturally are flushed on beforeExit (bounded to 2 s). After shutdown(), track and
captureException are ignored and reported as client_closed.
Errors
Request/response methods (ping, identify, signup, revenue) resolve to exactly one of data and
error, and never throw:
const { data, error } = await ravenkeep.signup({ userId })
if (error) {
console.warn(error.code, error.status, error.message) // never contains your keys
}error is a RavenkeepError with code, status (HTTP status, 0 when the request never reached the
API), issues (validation problems with their path) and details.
| code | Meaning |
|---|---|
| validation_failed | Invalid input, refused before sending (issues), or by the API (422). |
| missing_secret_key / secret_key_required | No key, or the publishable key was used. |
| invalid_api_key | Unknown, rotated or deleted key (401). |
| rate_limited | Too many requests (429); retried automatically when Retry-After is short. |
| currency_mismatch | Revenue currency differs from the project currency (422). |
| network_error / timeout | The API could not be reached, or the response body was cut (status 0); retried automatically (revenue() only with a reference). |
| invalid_response | The API answered 2xx without a JSON body (a proxy or gateway page in between); not retried. |
Fire-and-forget failures go to onError (by default one console.warn per code): validation_failed
(an invalid event or crash was dropped), queue_overflow (details.dropped), items_rejected
(details.rejected), an API code with details.dropped after the retries, client_closed,
flush_timeout, missing_identity_secret.
new Ravenkeep({ secretKey, onError: error => logger.warn({ code: error.code, details: error.details }, error.message) })Delivery guarantees
- Batches of at most 500 events / 50 crashes and under 1 MiB; one request in flight per queue.
- Network errors, timeouts, 408, 429 and 5xx are retried with exponential backoff and jitter (1 s → 30 s,
maxRetries), honoringRetry-After. A batch the API finds too large is split. Other 4xx are not retried. - Past
maxQueueSizethe oldest items are dropped (reported once per burst). The queue is a FIFO with O(1) push and shift, so a largemaxQueueSizeduring a long outage costs the same pertrack()as a small one. - A batch whose response body is cut by the timeout or the network is retried like any network failure: it is never counted as delivered.
messageIddeduplication on the API makes retries safe.
Self-hosting and testing
new Ravenkeep({ secretKey, host: 'https://ravenkeep.internal.example.com' })
new Ravenkeep({ secretKey: 'rk_secret_test', fetch: myFakeFetch, flushIntervalMs: 0 })
const { data } = await ravenkeep.ping() // { projectId, projectName, keyKind: 'secret' }