Installation
The package is public on npm. Install it for a bundled app, or load the self-contained bundle with a script tag; it exposes window.Ravenkeep. The script tag loads its two lazy files (ravenkeep-extras.global.js and ravenkeep-widget.global.js) from its own directory, with the same nonce.
npm install @ravenkeep/browserpnpm add @ravenkeep/browser<!-- jsDelivr serves the npm package; pin the version you test with -->
<script src="https://cdn.jsdelivr.net/npm/@ravenkeep/browser@0.4.0/dist/ravenkeep.min.global.js"></script>
<script>
const ravenkeep = window.Ravenkeep.init({ publishableKey: 'rk_pub_lumen_xxxxxxxxxxxx' })
</script>To serve the script tag from your own site instead of the CDN, for example under a Content Security Policy that allows no third-party host, install the package as above, copy its script files (the core and the files it loads on demand) into the static files of your site, side by side, and load the core from there (/ravenkeep.min.global.js):
npm install @ravenkeep/browser
# the core and the files it loads on demand, side by side
cp node_modules/@ravenkeep/browser/dist/*.global.js public/- When you self-host, copy the files again after each upgrade, so the core and its lazy files always come from the same version. On the CDN, change the version in the URL (
@ravenkeep/browser@<version>). - The first releases are 0.x versions (the package is 0.4.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 contract as it is built: what came with a later version says so.
Quickstart
Initialize the SDK once, as early as possible on every page, so the first visit is attributed and early errors are captured. Identify the user once they sign in to open a widget session, and call reset() when they sign out.
import { Ravenkeep } from '@ravenkeep/browser'
// The publishable key is safe in browser code; it only works from your allowed origins.
export const ravenkeep = Ravenkeep.init({
publishableKey: 'rk_pub_lumen_xxxxxxxxxxxx',
})
// Once your user signs in. userHash comes from your backend (identity verification).
await ravenkeep.identify({ userId: user.id, userHash, email: user.email })
ravenkeep.open('chat') // the support chat and feature request widgetThe publishable key is read from browser code by design. It can only do what a visitor may do (open their own widget session, vote, report a browser error) and only from the origins you allow in the project settings. Construction never throws, even without a key: the first call reports the problem to onError.
Configuration
| Option | Default | Meaning |
|---|---|---|
publishableKey | none | The project publishable key, rk_pub_.... A secret key is refused locally and never sent. |
host | https://api.ravenkeep.io | API origin. Change it only for a self-hosted Ravenkeep. |
captureErrors | true | Report window.onerror and unhandledrejection, with breadcrumbs. |
sampleRate | 1 | Share of captured errors that are sent, from 0 to 1. |
maxErrorsPerMinute | 10 | Errors sent per minute at most, captured and manual together. |
beforeSend | none | Last look at every crash: return it (possibly scrubbed), or null to drop it. |
release, environment | none | Attached to crashes. The API uses production when environment is missing. |
autoPageviews | false | Send $pageview on load and after every SPA navigation (History API); since 0.3.0, also the time spent on each of those pages ($pageleave, see Page views and events). |
attribution | { storage: 'cookie' } | { cookieDomain?, storage?: 'cookie' | 'localStorage' }, or false when the visitor has not consented: no first touch or visit is captured or kept (since 0.2.0, init also forgets the ones the site kept). The anonymous id is still stored, in localStorage, and sent with every event and crash. |
widget | {} | { position?, accentColor?, locale?, launcher?, scriptUrl? }, or false to turn the widget off. |
widget.position | bottom-right | Corner of the launcher and the panel: bottom-right or bottom-left. |
widget.accentColor | project setting | CSS color of the launcher, the buttons and the customer's own messages. |
widget.locale | browser language | en or pt-BR. |
widget.launcher | true | false hides the floating button; open the widget from your own UI with open(). |
widget.scriptUrl | next to the script | Script tag only: where ravenkeep-widget.global.js lives. |
fetch | global fetch | A custom fetch, for tests or proxies. |
onError | one warning per code | Receives every failure of the background pipeline as a RavenkeepError. |
Identify users
identify opens a widget session for the signed-in user (POST /v1/widget/session) and resolves to { data, error }. Once it succeeds, the widget appears, and the errors and page views of this page count as that customer's: their batches carry the widget session token. Before identify, and after reset(), they are anonymous.
const me = await fetch('/api/me').then(response => response.json())
const { error } = await ravenkeep.identify({
userId: me.id,
userHash: me.ravenkeepUserHash,
email: me.email,
name: me.name,
account: { id: me.orgId, name: me.orgName },
})
if (error?.code === 'identity_verification_failed') console.warn('Check the identity secret on the server')
// On sign-out: forgets the session in this tab, closes the widget, starts a new anonymous id.
logoutButton.addEventListener('click', () => ravenkeep.reset())- The session lasts 12 hours and is renewed 5 minutes before it expires; the renewal continues the same session, and the realtime connection switches to the new token at once. It is kept in memory and in
sessionStorage, so a reload in the same tab makes no new request. - Call
identifyon every page load once the user is known. When the identity has not changed and the session is still fresh, no request is made. - A stored session is only reused by an
identifyfor the same user with the same input. Another user of the tab always gets a new session, even if the app forgotreset(). - The request carries only the fields you pass: leave
nameoremailout rather than sending an empty value. - Your user hash is never stored, and the SDK never logs keys, tokens or hashes.
Identity verification
Anyone can read the publishable key, so a userId alone proves nothing. Your backend signs the user id with the project identity secret and the page passes the result as userHash. When the project requires verification (the default), a widget session without a valid hash fails with identity_verification_failed and nothing is written. If you turn verification off, a session without a valid hash still opens, but data.verified is false: it never sees the stored profile nor earlier conversations of that user, only the ones it started in this browser.
import { Ravenkeep } from '@ravenkeep/node'
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, ravenkeepUserHash: ravenkeep.widgetUserHash(req.user.id) })
})The hash is hex(HMAC-SHA256(identitySecret, userId)). Owners and admins find the identity secret in the project settings; rotating it invalidates every hash at once.
Attribution
On the first visit the SDK stores the UTM parameters, click ids, referrer and landing page in a first-party cookie (with a localStorage copy). Later pages and later visits never replace it, even when they carry other UTMs. Send it with your signup request so your backend can report it with signup.
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, attribution: ravenkeep.getAttribution() }),
})When your marketing site and your app live on different subdomains, initialize the SDK on both with the same attribution.cookieDomain (such as .example.com): the first touch recorded on www is read on app. A referrer from your own domain counts as internal navigation. clearAttribution() forgets the stored value (since 0.2.0, the visit too) but does not stop the SDK from keeping new ones: the next event starts a new visit. attribution: false means the visitor has not consented, and captures nothing. To withdraw consent fully, call await ravenkeep.shutdown(), then initialize with attribution: false on that page and every later one until consent returns: since 0.2.0, that also forgets the first touch and the visit the site kept, per origin: their cookie on this host and its parent domains, and this origin's localStorage copy (a sibling host's own cookie, or another origin's storage, is forgotten when that app does the same). It is not a per-app switch: on a shared cookieDomain, every init (each page load) of an app with attribution: false clears the first touch and the visit your other apps keep there, so give every app the visitor's consent the same way. The anonymous id stays, stored and sent with attribution: false too, since crashes, events and the widget's conversations are tied to it.
| 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. |
Error capture
With captureErrors on, uncaught errors and unhandled promise rejections are reported with their stack, the page URL, the user agent and the last 50 breadcrumbs (navigations, console.error calls and failed requests, without query strings). Existing window.onerror handlers keep running. The same bug is sent once per tab session, and at most 10 errors per minute, so a loop that throws cannot flood your project. An error without a script position, such as ResizeObserver loop completed with undelivered notifications., is grouped by its message, whatever page it happened on.
try {
await uploadFile(file)
} catch (error) {
ravenkeep.captureException(error, { tags: { area: 'upload' }, context: { fileSize: file.size } })
showToast('Upload failed')
}- After
identify, a crash is attributed to that customer: the console shows who was hit and the revenue at risk. - Crashes and events leave in batches about a second after they occur, and are retried with backoff on network errors, 429 and 5xx responses.
- When the page is hidden or closed, what is still queued leaves with
navigator.sendBeacon. Beacons cannot carry headers, so the publishable key travels in?apikey=and the session token in the body. - Use
beforeSendto scrub or drop a crash, andsampleRateto send only a share of them. - Set
releaseandenvironmentso crash groups are separate per environment and regressions are found per release. Minified bundles group best with afingerprint, or with function names kept in the build.
Page views and events
trackPageview() sends $pageview with the URL, path, referrer and title, and the address's utm_source, utm_medium, utm_campaign, utm_term and utm_content when it has them; autoPageviews: true sends one on load and after every SPA navigation. track(name, properties) sends a custom event, but with the publishable key the API only accepts the names listed in the project's public event names (in the console: Settings, then Projects, then the project's Install tab, under Public events); send the others from your backend with the Node SDK, which also ties them to the contact.
export const ravenkeep = Ravenkeep.init({ publishableKey: 'rk_pub_lumen_xxxxxxxxxxxx', autoPageviews: true })
// Or by hand after each route change:
router.afterEach(() => ravenkeep.trackPageview({ section: 'billing' }))Since version 0.2.0, every event carries sessionId, the visit it belongs to. A visit ends after 30 minutes without an event or 24 hours after it began; reset(), an identify() of another user than the page's, and a page view that lands with another campaign (its utm_source, utm_medium and utm_campaign, each trimmed, then compared exactly) start a new one; the visit is the browser's, so an identify() of another user in one tab starts a new visit in every tab. The visit lives in the same first-party storage as the anonymous id (a first-party cookie, on cookieDomain when set and else the host's, mirrored in localStorage), so your tabs share it, and so do your subdomains when every one of them initializes the SDK with the same cookieDomain. getSessionId() returns it (and starts one when none is current): pass it to your backend and give it to the Node SDK's track to count a server event in the same visit. With attribution: false nothing is stored for the visit either: events carry no sessionId, getSessionId() returns null, and Ravenkeep groups the person's events by the 30-minute gap. Events sent with the publishable key are timed when Ravenkeep receives them, so a batch held back over 30 minutes (a device asleep or offline) can split a visit the browser kept as one. Since version 0.4.0, a tab that comes back to its page in a visit its page view was not sent in sends that $pageview again into the visit as the tab becomes visible, starting it when none is current: after the visit ended while the tab was away (hidden over 30 minutes after the last event, or past the visit's 24 hours), or when an event, another tab or getSessionId() started a new one meanwhile. The view keeps the page's address as it is (a landing's utm_* stay in its url), its title and the properties you named it with, but has no utm_* properties and no referrer, so a visit it starts has no campaign. Each tab sends its own: two tabs of one page that come back each send the view. It needs attribution on: nothing is sent again with attribution: false. After reset() nothing is sent again until a page view is named again, so a sign-out never sends the signed-out user's page under the new anonymous id. A visit that ends while the tab stays visible starts at the next event without a page view (the view joins it at the tab's next return), and an SPA navigation then sends the earlier page's $pageleave into it first. Metrics, then Sessions, reports the visits.
Since version 0.3.0, autoPageviews: true also measures how long each page held the visitor and sends it as $pageleave, with the page's path and engaged_ms: the time the page was visible and in use, counting at most 60 seconds after it was shown or the visitor last acted (a pointer press or move, a key, the wheel, a touch, a video or audio playing with sound; a page that scrolls itself or a muted video does not count). A page read for 30 seconds without a click reports about 30 seconds; a hidden tab, or one left open without input, adds nothing. The time is sent when the next page replaces it (before that page's $pageview, so it stays in its visit), when the page is hidden or closed (in the same request as the page's other events), and every 15 minutes while it holds time; what a page shown again adds after that is sent with resumed: true (since 0.4.0, not after a return that sends the page view again into another visit: that time is the page's first there). A segment holds at most 30 minutes. The measurement is a small file the SDK loads after the first page view (ravenkeep-engage.global.js beside the script tag's file; a lazy chunk of your bundle with npm); it stores nothing, and its events carry the visit's sessionId only when the page's events do (none with attribution: false). There is no option to turn it off alone: autoPageviews: false stops the page views and their time, and pages you send with trackPageview() yourself get no time. $pageleave never counts toward your plan's events (a cycle stores a bounded number of them: the larger of 100,000 and 100 per metered event; past it, items_rejected with leave_limit_reached). Metrics shows the time in Pages & screens and per feature, in Features.
Support chat and feature requests
After identify, a launcher opens the widget with two tabs. Chat is a realtime conversation with your team, with attachments, typing indicators in both directions, "Seen" receipts and unread counts. Feature requests lists the requests your team published, with status, search and upvotes, and a form to suggest a new one.
const ravenkeep = Ravenkeep.init({ publishableKey: 'rk_pub_lumen_xxxxxxxxxxxx', widget: { launcher: false } })
helpButton.addEventListener('click', () => ravenkeep.open('chat'))
roadmapLink.addEventListener('click', () => ravenkeep.open('features'))
ravenkeep.on('unread', count => {
helpBadge.textContent = count > 0 ? String(count) : ''
})- The widget renders inside a Shadow DOM and loads on demand, outside the core bundle. It follows the dark mode and reduced motion settings of the device, traps focus while open and closes with Escape.
- Messages arrive over a realtime connection. When it drops, the widget says it is reconnecting, checks for new messages every 10 seconds, tries to reconnect at least every 10 seconds and reloads the conversation once it is back.
- Attachments go up to 10 MB each: images, PDF, text, CSV, JSON, archives, Office documents, MP4 and WebM video and MP3 audio. SVG and HTML are refused, and the widget says why a file was not accepted.
- Attachment links are signed and expire after an hour; the widget reloads a conversation on screen before its links expire, and when an image fails to load.
- A vote is idempotent: voting twice keeps one vote. Suggestions stay private until your team accepts, merges or declines them; when one is merged into a request, the customer's vote moves there.
- The widget speaks English and Brazilian Portuguese, from
widget.localeor the browser language.
Frameworks and server rendering
Importing the package on the server is safe; it touches browser globals only when called. Initialize it once in browser code, outside your components, so re-renders and React StrictMode never initialize it twice, and identify the user whenever the signed-in id changes. In a server-rendered app, put the init call in a module that only the browser loads.
import { Ravenkeep } from '@ravenkeep/browser'
import { useEffect } from 'react'
// Once per page load, at module scope: never inside a component or an effect.
export const ravenkeep = Ravenkeep.init({ publishableKey: globalThis._importMeta_.env.VITE_RAVENKEEP_KEY })
export function useRavenkeepIdentity(user: User | null) {
const userId = user?.id
const userHash = user?.ravenkeepUserHash
useEffect(() => {
if (userId && userHash) void ravenkeep.identify({ userId, userHash })
else ravenkeep.reset()
}, [userId, userHash])
}Content Security Policy
If your site sends a Content Security Policy, allow the API and the realtime connection the widget session names:
| Directive | Add |
|---|---|
connect-src | The API host (https://api.ravenkeep.io) and the realtime WebSocket host of the widget session (wss://api.ravenkeep.io, path /ws). |
script-src | Script tag only: the host serving ravenkeep.min.global.js (https://cdn.jsdelivr.net with the CDN tag, your own origin when self-hosted). The lazy files are injected with the main script's nonce. |
img-src | The host of attachment links, so image attachments show a preview: https://<account id>.r2.cloudflarestorage.com when the API stores attachments in Cloudflare R2 (the hosted API), the API host itself on a stack with local storage. It is the host of the url an attachment comes with. |
style-src | Nothing on current browsers: the widget uses constructable stylesheets. Very old browsers fall back to a <style> element inside the Shadow DOM, which needs 'unsafe-inline'. |
API reference
Ravenkeep.init(options) (or new Ravenkeep(options)) returns the client and never throws. identify resolves to { data, error }; the other methods return void or a promise that never rejects.
identifyPromise<{ data, error }>Opens a widget session for the signed-in user (with userHash). No request when the same identity has a fresh session.
resetvoidSign-out: forgets the session, the user and the anonymous id, starts a new visit and closes the widget.
getAttributionAttribution | nullThe stored first touch of this visitor.
clearAttributionvoidForgets the stored first touch and, since 0.2.0, the visit; it does not stop them (the next event starts a new visit). To stop, see consent under Attribution.
getSessionIdstring | nullSince 0.2.0. The current visit's id, the sessionId of every event; starts a visit when none is current, never extends one (null during SSR and with attribution: false).
captureExceptionvoidQueues an error with its stack, tags, context, level or fingerprint.
trackPageviewvoidQueues a $pageview of the current page, with optional properties.
trackvoidQueues a custom event; the publishable key accepts public event names only.
openvoidOpens the widget on 'chat' or 'features'; before identify it waits for the session.
closevoidCloses the widget.
on() => voidSubscribes to 'unread', 'open', 'close' or 'message' and returns the unsubscribe function.
flushPromise<void>Sends the queued crashes and events now. Never rejects.
shutdownPromise<void>Removes the widget and every handler, makes one last delivery attempt and hands the rest to sendBeacon.
Events
| Event | Payload | When |
|---|---|---|
unread | number | The operator replies the customer has not read changed. A new listener gets the current count at once. |
message | the message | A new reply from your team arrived. |
open | chat or features | The widget panel opened. |
close | none | The widget panel closed. |
HTTP requests behind each call
| Method | Request to https://api.ravenkeep.io |
|---|---|
identify | POST /v1/widget/session |
captureException, captured errors | POST /v1/crashes (batched; the session token once identified) |
trackPageview, track | POST /v1/events (batched; the session token once identified) |
| widget chat | GET and POST /v1/widget/conversations, their messages and read, POST /v1/widget/attachments |
| widget requests | GET /v1/widget/feature-requests, POST and DELETE .../vote, POST suggestions |
Every request carries the publishable key in the apikey header and X-Ravenkeep-SDK: browser/<version>. Widget requests add the session token in X-Ravenkeep-Widget-Token, never in Authorization.
Errors and troubleshooting
Failures resolve as { data: null, error } from identify, or reach onError for everything that runs in the background. error is a RavenkeepError with a stable code, the HTTP status (0 when the request never reached Ravenkeep) and, for validation failures, the issues.
| Code | Status | Meaning |
|---|---|---|
network_error | 0 | The request never reached Ravenkeep. Queued items are retried; the widget retries when the connection returns. |
timeout | 0 | No complete answer within 10 seconds. Treated like a network error. |
missing_publishable_key | 0 | The client was created without a key: nothing is sent. |
publishable_key_required | 0 | A secret key was passed to the browser SDK. It is never sent; use the publishable key. |
not_identified | 0 | A widget call before identify succeeded, or after reset(). |
validation_failed | 0 or 422 | The input is invalid (see issues), or an item cannot be serialized to JSON. |
invalid_api_key | 401 | The key is unknown or was rotated. |
identity_verification_failed | 401 | The userHash is missing or does not match the user id. |
invalid_widget_token | 401 | The widget session expired or was revoked. The SDK renews it once and retries. |
origin_not_allowed | 403 | The page origin is not in the project allowed origins. |
payload_too_large | 413 | An attachment over 10 MB, or a batch over its limit. |
unsupported_media_type | 415 | An attachment of a type the API does not accept (SVG, HTML, or bytes that contradict the type). |
rate_limited | 429 | More than 20 requests per second for one publishable key and IP address in a route family (events, crashes, widget), or more than 10 votes, suggestions, messages or uploads per minute from one contact. |
items_rejected | 200 | The API took the batch but refused some items (details.rejected), such as an event name that is not public. |
queue_overflow | 0 | Items were dropped because the queue was full. |
client_closed | 0 | track, trackPageview or captureException after shutdown(). |
invalid_response | 2xx | The API answered without a JSON body or without the expected fields (a proxy or captive portal page in between). |
flush_timeout | 0 | shutdown() could not deliver everything; the rest was handed to sendBeacon. |
widget_load_failed | 0 | The widget code could not be loaded (blocked script, offline). |
- The launcher does not appear.
identifyfailed; log theerrorit resolves to. - Crashes show as anonymous. They were sent before
identifysucceeded, or afterreset(). - Custom events are refused (
items_rejected). The name is not one of the project's public event names: send it from your backend with the Node SDK. - Errors from browser extensions show up. Drop them in
beforeSend, or ignore their group in the console.
Compatibility
- Evergreen browsers (the last two versions of Chrome, Edge, Firefox and Safari).
- The core (attribution, errors, page views, identify) is about 8 KB gzipped; error processing and the widget (under 45 KB) load on demand.
- ESM, CommonJS and the script tag bundle, with no runtime dependencies.
- Semantic versioning: patches fix, minors add, majors change a contract. Releases are tagged
sdk-browser-vX.Y.Z.