@ravenkeep/browser
v0.4.0
Published
Ravenkeep SDK for browsers: first-touch UTM attribution, error capture, pageviews and the support widget (chat + feature requests).
Downloads
1,177
Maintainers
Readme
@ravenkeep/browser
The Ravenkeep SDK for browsers. Keep each visitor's first-touch attribution (UTMs, click ids, referrer, landing page) so your backend can report signups with it, capture front-end errors, send pageviews and public events, and give signed-in customers the support widget: live chat with your team and feature requests they can vote on.
- Uses the publishable key (
rk_pub_…) only. The secret key belongs to@ravenkeep/node. - Never throws. Request/response methods resolve to
{ data, error }; the others are fire-and-forget. - Safe to import during SSR. Browser globals are only touched in a browser.
- The core is about 8 KB gzip. Error processing (≤ 9 KB since 0.4.0, ~7 KB in 0.3.0) and the widget (≤ 45 KB, Preact + UI + realtime) are separate chunks, loaded only when needed.
- ESM, CommonJS and a
<script>build (window.Ravenkeep). No runtime dependencies.
Installation
The package is public on npm:
npm install @ravenkeep/browser
# pnpm add @ravenkeep/browser · yarn add @ravenkeep/browser · bun add @ravenkeep/browserOr with a script tag. jsDelivr serves the npm package; pin the version you test with. The lazy chunks
(ravenkeep-extras.global.js, ravenkeep-widget.global.js and, since 0.3.0 with autoPageviews,
ravenkeep-engage.global.js) are loaded from the same directory as the main script (ravenkeep.min.global.js; ravenkeep.global.js is the same file under its older name):
<script src="https://cdn.jsdelivr.net/npm/@ravenkeep/[email protected]/dist/ravenkeep.min.global.js"></script>
<script>
const ravenkeep = Ravenkeep.init({ publishableKey: 'rk_pub_acme_…' })
</script>To serve the script tag from your own site instead (for example under a Content Security Policy without a
third-party host), copy the files from the installed package into your site's static files, side by side, load
/ravenkeep.min.global.js from there, and copy them again after each upgrade:
cp node_modules/@ravenkeep/browser/dist/*.global.js public/Quickstart
import { Ravenkeep } from '@ravenkeep/browser'
export const ravenkeep = Ravenkeep.init({
publishableKey: 'rk_pub_acme_…', // Project settings → Keys
attribution: { cookieDomain: '.acme.com' }, // share the first touch between www. and app.
autoPageviews: true,
release: import.meta.env.VITE_GIT_SHA,
})
// Once the user is signed in (userHash comes from your backend, see "Identity verification"):
await ravenkeep.identify({ userId: user.id, userHash: user.ravenkeepHash, email: user.email, name: user.name })
// On logout (always: it forgets the user's widget session in this tab):
ravenkeep.reset()Create one client per page and keep it. Ravenkeep.init and new Ravenkeep(options) are equivalent.
Configuration
| Option | Default | Description |
|---|---|---|
| publishableKey | — | The project's publishable key (rk_pub_…). If it is empty, nothing is sent and onError gets missing_publishable_key. A secret key is refused locally with publishable_key_required and is never sent. |
| host | https://api.ravenkeep.io | API origin, for self-hosted installs. |
| captureErrors | true | Capture window.onerror and unhandledrejection, plus breadcrumbs. |
| sampleRate | 1 | Share of errors that are sent (0–1). |
| maxErrorsPerMinute | 10 | Rate limit on sent errors, counting both captured and manual ones. |
| beforeSend | — | (crash) => crash \| null. Change or drop each crash before it is sent. |
| release, environment | — | Attached to crashes. The API defaults environment to production. |
| autoPageviews | false | Send $pageview on load and after every SPA navigation (History API), and, since 0.3.0, each view's engaged time ($pageleave, see Engaged time). |
| attribution | { storage: 'cookie' } | { cookieDomain?, storage?: 'cookie' \| 'localStorage' }, or false when the visitor has not consented: no first touch or visit is captured or kept, and init forgets the ones the site kept (see Consent). The anonymous id (rk_aid) is still stored (localStorage) and sent. |
| widget | {} | { position?: 'bottom-right' \| 'bottom-left', accentColor?, locale?: 'en' \| 'pt-BR', launcher?: boolean, scriptUrl? }, or false to turn the widget off. |
| fetch | global fetch | A custom fetch, for tests or proxies. |
| onError | warns once per code | Receives every failure of the background pipeline (RavenkeepError). |
API
| Method | Returns | What it does |
|---|---|---|
| identify({ userId, userHash?, email?, name?, account? }) | Promise<{ data, error }> | Opens the widget session (POST /v1/widget/session) and tags later crashes and events with userId; while the session exists their batches carry its token, so the API counts them as this customer's. |
| reset() | void | Logout. Forgets the session (memory and the tab's stored copy) and the user, closes the widget, and starts a new anonymous id and a new visit. Call it on every sign-out. |
| getAttribution() | Attribution \| null | The first touch of this browser. |
| clearAttribution() | void | Forgets the first touch and the visit, without stopping them: the next event starts a new visit. To stop, see Consent. |
| getSessionId() | string \| null | The current visit's id, sent as sessionId on every event (see Sessions). Reading it never extends the visit, but starts one when none is current. null during SSR and with attribution: false. |
| captureException(error, { tags?, context?, level?, fingerprint? }) | void | Reports an error. |
| trackPageview(properties?) | void | Sends $pageview with url, path, referrer, title and the address's utm_source, utm_medium, utm_campaign, utm_term, utm_content. |
| track(name, properties?) | void | A custom event. With the publishable key, only names in the project's public event names are accepted. |
| open('chat' \| 'features'), close() | void | Opens or closes the widget panel. open() before identify() waits for the session. |
| on('unread' \| 'open' \| 'close' \| 'message', listener) | unsubscribe function | unread: unread operator messages (a new listener gets the current count right away). message: a new operator message. |
| flush() | Promise<void> | Sends queued crashes and events now (unless a retry is already waiting for its backoff). Never rejects. |
| shutdown() | Promise<void> | Removes every handler and patch, unmounts the widget, and makes one last attempt to send what is queued (no backoff wait; each request bounded by the 10 s timeout). What the API did not take goes to sendBeacon and is reported once as flush_timeout (details.pending). Nothing keeps running afterwards. Never rejects. |
The package also exports parseAttribution(url, referrer?, { siteHost? }), isEmptyAttribution,
RavenkeepError, RAVENKEEP_API_URL, SDK_VERSION and the wire types.
error is a RavenkeepError with a stable code. That is either the API's code
(identity_verification_failed, rate_limited, origin_not_allowed…) or one of the SDK's own codes:
network_error and timeout (status 0; a response body cut by the timeout is a timeout too, never an
empty success), missing_publishable_key, publishable_key_required, validation_failed, not_identified,
invalid_response (a 2xx without a JSON body, such as a proxy page), items_rejected, queue_overflow,
flush_timeout, client_closed or widget_load_failed. On failure data is null.
Attribution: marketing site → app → signup
The first page a visitor lands on is kept forever: UTM source, medium, campaign, term and content,
gclid/fbclid/msclkid, the referrer and the landing page. Later visits never overwrite it. It is
stored in a first-party cookie, mirrored in localStorage, and the localStorage copy is used when cookies
are refused. A referrer from your own site (or from cookieDomain) counts as internal navigation and is
dropped.
When the marketing site and the app run on different subdomains, initialize the SDK on both with the same
cookieDomain. The first touch is then recorded on www.acme.com and read on app.acme.com:
// www.acme.com (marketing): attribution only
Ravenkeep.init({ publishableKey, attribution: { cookieDomain: '.acme.com' }, widget: false, captureErrors: false })
// app.acme.com (product)
const ravenkeep = Ravenkeep.init({ publishableKey, attribution: { cookieDomain: '.acme.com' } })Send the attribution along with your signup form. Your backend then records the signup with
@ravenkeep/node. Attribution is written once per contact and never
overwritten:
// Browser
await fetch('/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password, attribution: ravenkeep.getAttribution() }),
})
// Backend (Node)
import { Ravenkeep } from '@ravenkeep/node'
const ravenkeep = new Ravenkeep({ secretKey: process.env.RAVENKEEP_SECRET_KEY! })
app.post('/api/signup', async (req, res) => {
const user = await users.create(req.body)
await ravenkeep.signup({ userId: user.id, email: user.email, attribution: req.body.attribution })
res.status(201).json({ id: user.id })
})clearAttribution() forgets the stored value, without stopping the next ones (to stop, see Consent). attribution:
false captures nothing.
Consent
attribution: falsemeans this visitor has not consented (before consent, or after it was withdrawn): it captures and stores nothing for attribution or the visit, sends nosessionId, and at every init forgets what the site kept: the first touch (rk_attr) and the visit (rk_sid). Removal is per origin: their cookie on this host and on each of its parent domains, and this origin's localStorage copy — not a host-only cookie of a sibling host (wwwwithoutcookieDomain) or another origin's localStorage, which that app forgets when it inits withattribution: false.- It is not a per-app "attribution off": on a shared
cookieDomain, every init (each page load; not an SPA's navigations) of an app withattribution: falseclears the site-wide first touch and visit the other apps keep there (your marketing site's first touch is then lost). Give every app of the site the visitor's consent, the same way. clearAttribution()forgets the same two now, but does not stop them: this client's next event starts a new visit (stored again), and the next init with attribution on captures a new first touch.- Full withdrawal:
await ravenkeep.shutdown(), thenRavenkeep.init({ …, attribution: false })on this page and every later one until consent returns. - The anonymous id (
rk_aid) is not attribution's: withattribution: falsetoo, it is stored (in localStorage; with attribution on, in the cookie as well) and sent with every event and crash, as events, crashes and the widget's conversations are tied to it;reset()replaces it. Whether your consent rules allow that before consent is yours to check.
Sessions
Every event carries sessionId, the visit it belongs to (0.2.0 and later; Metrics → Sessions reports them). A
visit is a random UUID kept in the same first-party store as the anonymous id (rk_sid: a first-party cookie, on
cookieDomain when set and else the host's, mirrored in localStorage), so tabs share it, and so do subdomains when every app of the site initializes the SDK
with the same cookieDomain. A new visit starts after 30 minutes without an event, 24 hours after the visit began,
when the clock moved back more than 30 minutes, after reset(), after an identify() of another user than the
page's, and when a page view lands with a campaign (utm_source, utm_medium, utm_campaign, each trimmed as every
UTM is read, then compared by position and exactly) other than the visit's. The visit is the browser's: an
identify() of another user in one tab starts a new visit for every tab. Without storage (a privacy mode refusing
both), the visit lasts for the page's life. With attribution: false nothing is stored for the visit either: events
carry no sessionId and getSessionId() returns null (the API groups the person's events by the 30-minute gap).
Since 0.4.0, a tab that comes back to its page in a visit its view was not sent in sends the page's
$pageview again into that visit as the tab becomes visible, starting it when none is current. That happens after the
visit ended while the tab was away (hidden over 30 minutes after the last event, or past the visit's 24 hours), and
also when something else started the current visit meanwhile (an event of the page or of another tab, or
getSessionId()). The view keeps its url as the address is (a landing's utm_* and click ids stay in it), its title
and the properties you named it with (as they were then), but has no utm_* properties and no referrer: the campaign
and the referrer brought the earlier visit, so a visit the view starts has no campaign. An event or an SPA navigation
after it joins that visit. It needs attribution on (the default), as the visit does: with attribution: false
nothing is sent again. Only the page the SDK last sent a view of is sent again, and each tab sends its own: two tabs of
one page that come back each send the view. After reset() nothing is sent again until a view is named again (the
next navigation with autoPageviews, or your own trackPageview()): a sign-out never sends the signed-out user's page
again under the new anonymous id. A visit that ends while the tab stays visible starts at the next event
without a view (the view joins it at the tab's next return), and an SPA navigation then sends the earlier page's
$pageleave (engaged time) into it before the new $pageview. In 0.3.0 and earlier nothing is sent at the return: the
visit the next event starts has no view of the page the tab came back to, and its first view is the next navigation's.
Events sent with the publishable key are timed when the API receives them, not by the browser's clock: a batch held back over 30 minutes (a device asleep or offline) can split a visit the browser kept as one, as Ravenkeep applies the 30-minute gap within a visit's events too.
To count a server event in the same visit, send ravenkeep.getSessionId() to your backend and pass it to the Node
SDK: ravenkeep.track('report.exported', { userId, sessionId }).
Engaged time
Since 0.3.0, autoPageviews: true also measures how long each page view held the visitor (Metrics → Pages & screens
and Features report it). A lazily loaded chunk (engage, under 1 KB gzip) counts the time the page is visible and the
visitor is there: input (pointer, keys, wheel, touch) or a video or audio playing with sound keeps it going, and it
stops 60 seconds after the last one (reading without a click still counts; a tab left open over lunch does not, nor a
page that scrolls itself or a muted video playing on its own). Hidden time never counts.
The time is sent as a $pageleave event (path, engaged_ms, and resumed: true on a view's later segments) when
the view ends (an SPA navigation to another address; a hash change is the same view), when the page is hidden or left
(in the same sendBeacon as the queue), and every 15 minutes while the view holds engaged time; a segment never
exceeds 30 minutes. A view that was never visible or never held engaged time sends nothing. Since 0.4.0,
when a return sends the view again into another visit (see Sessions), its next segment counts as the
view's first there (no resumed). Nothing is stored for it:
the measurement lives in the page, so attribution: false changes only what every event carries (no sessionId).
Leave events do not count toward your plan's events. There is no separate switch: autoPageviews: false stops the
views and their time together, and pages that call trackPageview() themselves get no time.
On an SPA navigation the ending view's $pageleave is queued just before the next $pageview, so it stays in its
visit even when the next view starts another (a campaign). The chunk sees navigations through trackPageview, which
it wraps while it runs (shutdown() restores it).
Identity verification
Projects require verified identities by default, the same way Intercom does. The widget session is
refused unless userHash = hex(HMAC-SHA256(identitySecret, userId)) is sent. Compute the hash on your
server, because the identity secret must never reach the browser, then hand it to the page:
// Backend: new Ravenkeep({ secretKey, identitySecret: process.env.RAVENKEEP_IDENTITY_SECRET })
res.json({ user, ravenkeepHash: ravenkeep.widgetUserHash(user.id) })
// Browser
const { error } = await ravenkeep.identify({ userId: user.id, userHash: me.ravenkeepHash, email: user.email })
if (error?.code === 'identity_verification_failed') console.warn('Check the identity secret')The session token lasts 12 hours. It is kept in memory and in sessionStorage for reloads within the
same tab, and is renewed 5 minutes before it expires. Besides the widget routes, it proves who the
customer is to /v1/events and /v1/crashes: the API stores publishable-key events and crashes as
anonymous unless they come with a valid token of the same contact (X-Ravenkeep-Widget-Token on fetches,
a widgetToken body field on beacons, never in a URL). Before identify() succeeds, and after reset(),
batches carry no token.
A renewal presents the current token (X-Ravenkeep-Widget-Token on POST /v1/widget/session) so the new
session continues the same lineage: in a project without identity verification, an unverified session
(data.verified === false) only sees the conversations of its own lineage, and keeps them across renewals.
The last token of the tab's user is kept in memory past its expiry for that purpose (a tab that slept
overnight), is only ever presented for the same userId, and is forgotten by reset(). An unverified
session's contact carries only what identify sent, never the stored profile. Call identify() on every page load once the user
is known: when the identity has not changed and the token is still fresh, no request is made.
Which values win
identify() sends only the fields you pass: a missing, null or blank email, name or account name is left
out of the request, never sent as an empty value, and nothing is carried over from an earlier call. The API only
fills a name or an email the contact does not have yet: values your backend set with the secret key (signup,
identify in @ravenkeep/node) are never overwritten from the browser. An email sent without identity
verification is stored as unverified: your team can answer in the widget, but it never receives campaign emails
(flows skip the contact with the reason untrusted_email). The language of campaign emails comes from the
contact trait locale, which only your backend sets.
Stored sessions
A stored session is never used on its own. After a reload nothing is restored (no widget, no token)
until identify() runs, and the stored session is picked up only when the same userId identifies with
the same input (compared through a SHA-256 digest; the user hash itself is never stored). Another user of
the same tab always gets a new session, even if the app forgot reset() on logout. Without WebCrypto
(pages served over plain http: other than localhost) the session is not stored for reuse and each page
asks the API again.
The support widget
Once identify() succeeds, the widget loads and shows a launcher button in a corner. It runs inside a
Shadow DOM, so your CSS and its CSS never mix. The Chat tab lists the customer's conversations, lets
them write to you with attachments (up to 10 MB each), and shows replies live, with typing indicators and
"Seen" receipts (live, and from the conversation's operatorReadAt after a reload). The Feature
requests tab lets them search, sort, vote, and suggest new features; it refreshes each time it is shown
again (a suggestion merged into a request moves the customer's vote there). Requests they voted for show
an "Updated" badge when their status changes.
Attachments:
- Uploads the API refuses are explained in the composer: larger than 10 MB (
413 payload_too_large, also checked before uploading), a type it does not accept such as SVG or HTML (415 unsupported_media_type), too many uploads (429). An attachment the API refuses when the message is sent (already used, older than 24 hours:422onattachments) asks the customer to attach it again. - Images reserve their space from the
width/heightthe API returns (scaled into 220 × 160), so the thread does not jump while they load. - Attachment URLs are signed and expire after 1 hour. A thread on screen with attachments is reloaded (all the pages it shows) once its URLs are 45 minutes old, when it is reopened or the page becomes visible again after that, and when an image fails to load (at most once every 30 s).
Ravenkeep.init({
publishableKey,
widget: { position: 'bottom-left', accentColor: '#4f46e5', locale: 'pt-BR' },
})To use your own button, set launcher: false:
const ravenkeep = Ravenkeep.init({ publishableKey, widget: { launcher: false } })
helpButton.onclick = () => ravenkeep.open('chat')
ravenkeep.on('unread', count => (badge.textContent = count ? String(count) : ''))The widget follows prefers-color-scheme and prefers-reduced-motion. It behaves as a dialog: focus is
kept inside it, Escape closes it, new messages are announced to screen readers, and the tabs work with
the arrow keys. It is available in English and Brazilian Portuguese; the language comes from locale, or
otherwise from navigator.language. Messages arrive in real time over a WebSocket (the iii rbac-proxy,
chat-subscribe with scope: 'contact'; typing goes through chat::typing). While that connection is
down, the widget shows "Reconnecting…", checks for new messages every 10 seconds, retries the connection at
least every 10 seconds, and reloads the conversations once it is back. Each connection is followed by one
more refresh 2 seconds later (the subscription reaches the hub a moment after the socket opens), and a
renewed token reopens the socket at once, without going offline (the hub stops delivering to a socket whose
token expired). The iii client's
[iii] WebSocket error log on each failed attempt is silenced (the browser still logs its own network
error line).
Errors
Uncaught errors and unhandled rejections are captured. Existing window.onerror handlers keep running,
and their return value is respected. Each crash includes:
- the stack, parsed into frames marked in-app or not;
context.url, the user agent anduserId;- the last 50 breadcrumbs: SPA navigations,
console.errorcalls and failedfetchrequests (query strings are removed and the SDK's own requests are excluded).
console.error and the History API are wrapped at init; a logged value is previewed with a bounded
serializer (the first 10 arguments, about 1 KB each, arrays cut to 20 items), so logging a large object
costs no more than a small one. fetch is wrapped only once the extras chunk has loaded (after
identify(), the first error or open()): a page that never errors and never identifies keeps its own
fetch, and failed requests before that point are not in the trail. captureErrors: false installs none
of these wrappers (the History API is still observed with autoPageviews: true).
The same bug is sent once per tab session: grouping uses the API's normalized fingerprint, so a different line number or bundle hash still counts as the same bug. At most 10 errors are sent per minute.
An error the browser reports without an error object and without a script position, such as ResizeObserver
loop completed with undelivered notifications., has no stack: the source the browser names is the page itself,
so it is grouped by its message, whatever page (and ids in its address) it happened on.
Ravenkeep.init({
publishableKey,
sampleRate: 0.5,
beforeSend: crash => (crash.exception.message.includes('ResizeObserver') ? null : crash),
})
try {
await checkout()
} catch (error) {
ravenkeep.captureException(error, { tags: { step: 'payment' }, context: { cartId } })
}Crashes and events are sent in batches about a second after they occur. Failed batches (network errors,
timeouts, 408, 429, 5xx) are retried 3 times with backoff and Retry-After; new events during a backoff
join the waiting retry instead of forcing another request. An event or crash that cannot be serialized
to JSON (a BigInt or a cycle in properties or context) is dropped and reported as
validation_failed; the rest of the queue is still sent. When the page is hidden or unloaded, whatever
is still queued goes out with navigator.sendBeacon. Beacons cannot carry headers, so the publishable key
travels in ?apikey= for those requests. A crash too large for a beacon (64 KiB: a very long stack or
large breadcrumbs) is trimmed to fit (the last 10 breadcrumbs, a 16 KB stack, 50 frames); anything still
too large is sent with a normal request and reported if that fails.
Frameworks
Nuxt (plugins/ravenkeep.client.ts, which runs in the browser only):
import { Ravenkeep } from '@ravenkeep/browser'
export default defineNuxtPlugin(() => {
const ravenkeep = Ravenkeep.init({
publishableKey: useRuntimeConfig().public.ravenkeepKey,
attribution: { cookieDomain: '.acme.com' },
autoPageviews: true,
})
return { provide: { ravenkeep } }
})React:
// src/lib/ravenkeep.ts
export const ravenkeep = Ravenkeep.init({ publishableKey: import.meta.env.VITE_RAVENKEEP_KEY, autoPageviews: true })
// In your auth provider
useEffect(() => {
if (user) void ravenkeep.identify({ userId: user.id, userHash: user.ravenkeepHash, email: user.email })
else ravenkeep.reset()
}, [user])
// An unread badge
const [unread, setUnread] = useState(0)
useEffect(() => ravenkeep.on('unread', setUnread), [])Any SPA: autoPageviews: true tracks pushState/replaceState/popstate navigations, once per page.
To track pageviews manually, call ravenkeep.trackPageview() after each route change.
Content Security Policy
| Directive | Add |
|---|---|
| connect-src | Your API host (https://api.ravenkeep.io) and the realtime WebSocket host from the widget session (for example wss://api.ravenkeep.io). |
| script-src | Only for the script tag: the host that serves ravenkeep*.global.js. The lazy chunks are injected with the main script's nonce. |
| img-src | The attachment host, so image attachments can show previews: https://<account id>.r2.cloudflarestorage.com on an API that stores attachments in Cloudflare R2 (Ravenkeep's hosted API), the API host itself on a stack with local storage. It is the host of the url the widget receives for an attachment. |
| 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'. |
The API host must list your site in the project's allowed origins. Otherwise publishable-key requests
get 403 origin_not_allowed.
Bundle size
| Part | gzip | Loaded |
|---|---|---|
| Core (attribution, visits, capture hooks, queue, events, widget loader) | ≤ 8.125 KB since 0.4.0 (≤ 8 KB in 0.3.0) | with the page |
| Extras (stack parsing, fingerprinting, per-session dedupe, fetch breadcrumbs, widget session, widget events) | ≤ 9 KB (~7 KB in 0.3.0) | on the first error, on identify() or on open() |
| Engage (engaged time, since 0.3.0) | ≤ 1 KB | with autoPageviews, after the first page view |
| Widget (Preact, UI, iii realtime client) | ≤ 45 KB | once a widget session exists |
pnpm --dir packages/sdk-browser build && pnpm --dir packages/sdk-browser size checks the budgets (it also
runs before every publish, through prepublishOnly).
Demo
demo/index.html is a stand-in for a customer's site. It loads the script-tag build
(dist/ravenkeep.min.global.js) and reads its setup from the query string:
?key=rk_pub_…&host=http://localhost:5401&userId=…&userHash=…. Other parameters are email, name,
locale, position, accent, launcher=false, autoPageviews=true, sdk= for a different script URL,
and campaign parameters (utm_source=…) for the first touch. Its Pages links move to another page of
the site keeping the setup but not the campaign parameters; its buttons open the widget, throw an error,
reject a promise, capture an exception, track a page view and flush; its State panel shows the identify
result, the unread count and getAttribution().
pnpm --dir packages/sdk-browser build
pnpm --dir packages/sdk-browser demo # http://localhost:5410/demo/ (--port to change it)The demo server (scripts/demo-server.mjs) serves only demo/ and dist/, without caching.
http://localhost:* is an allowed origin of every new project; compute userHash with the project's
identity secret (hex(HMAC-SHA256(identitySecret, userId))). The end-to-end suite runs the widget there
against the live API and the console: pnpm --dir app test:e2e:widget (app/playwright.widget.config.ts
builds the SDK and starts the demo server on port 5410).
