# Ravenkeep browser SDK reference

**The browser SDK.** `@ravenkeep/browser` runs in your web app with the project publishable key. It keeps the first-touch attribution of each visitor, reports browser errors and page views, and embeds the support chat and feature request widget for signed-in customers.

- **rk_pub_**: Publishable key, accepted only from your allowed origins
- **~8 KB**: Gzipped core; error processing and the widget load on demand
- **Shadow DOM**: The widget is isolated from the CSS of your page

## 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**

```bash
npm install @ravenkeep/browser
```

**pnpm**

```bash
pnpm add @ravenkeep/browser
```

**Script tag**

```html
<!-- 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`):

**Terminal**

```bash
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.

**src/ravenkeep.ts**

```ts
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 widget
```

The 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`. |

> **Allowed origins**
>
> Requests with a publishable key must come from an origin in the project list, or they fail with `origin_not_allowed`. Patterns accept `*` for one label (`https://*.example.com`) and `**` for anything. New projects allow `http://localhost:*` and the origin of their site URL.

[How origin patterns match](https://ravenkeep.io/docs#origins)

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

**src/session.ts**

```ts
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 `identify` on 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 `identify` for the same user with the same input. Another user of the tab always gets a new session, even if the app forgot `reset()`.
- The request carries only the fields you pass: leave `name` or `email` out rather than sending an empty value.
- Your user hash is never stored, and the SDK never logs keys, tokens or hashes.

> **Your backend wins**
>
> The browser only fills a `name` or an `email` the contact does not have yet. Values set with the secret key (`signup` and `identify` in `@ravenkeep/node`) are never overwritten from a page. 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`). Set the email from your backend, or pass a `userHash`.

[Which values win, and the language of campaign emails](https://ravenkeep.io/docs#identity)

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

**server/me.ts**

```ts
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._

[The identity verification flow, step by step](https://ravenkeep.io/docs#identity)

## 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`.

**src/signup-form.ts**

```ts
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. |

[Attribution from your site to your app, end to end](https://ravenkeep.io/docs#attribution)

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

**src/upload.ts**

```ts
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 `beforeSend` to scrub or drop a crash, and `sampleRate` to send only a share of them.
- Set `release` and `environment` so crash groups are separate per environment and regressions are found per release. Minified bundles group best with a `fingerprint`, or with function names kept in the build.

[How crash grouping, releases and resolutions work](https://ravenkeep.io/docs#crashes)

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

**src/router.ts**

```ts
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.

[Naming events for Metrics, flows and goals](https://ravenkeep.io/docs#events)

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

**src/help-menu.ts**

```ts
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.locale` or the browser language.

[What your team sees in the console](https://ravenkeep.io/docs#widget)

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

**src/RavenkeepProvider.tsx**

```tsx
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.

| Method | Returns | Description |
| --- | --- | --- |
| `identify` | `Promise<{ data, error }>` | Opens a widget session for the signed-in user (with userHash). No request when the same identity has a fresh session. |
| `reset` | `void` | Sign-out: forgets the session, the user and the anonymous id, starts a new visit and closes the widget. |
| `getAttribution` | `Attribution \| null` | The stored first touch of this visitor. |
| `clearAttribution` | `void` | Forgets 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. |
| `getSessionId` | `string \| null` | Since 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). |
| `captureException` | `void` | Queues an error with its stack, tags, context, level or fingerprint. |
| `trackPageview` | `void` | Queues a $pageview of the current page, with optional properties. |
| `track` | `void` | Queues a custom event; the publishable key accepts public event names only. |
| `open` | `void` | Opens the widget on 'chat' or 'features'; before identify it waits for the session. |
| `close` | `void` | Closes the widget. |
| `on` | `() => void` | Subscribes to 'unread', 'open', 'close' or 'message' and returns the unsubscribe function. |
| `flush` | `Promise<void>` | Sends the queued crashes and events now. Never rejects. |
| `shutdown` | `Promise<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.** `identify` failed; log the `error` it resolves to.
- **Crashes show as anonymous.** They were sent before `identify` succeeded, or after `reset()`.
- **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`.

[Continue with the Node SDK](https://ravenkeep.io/docs/node)

---

Markdown rendering of https://ravenkeep.io/docs/browser. The other docs pages in Markdown: https://ravenkeep.io/docs/index.md, https://ravenkeep.io/docs/node.md, https://ravenkeep.io/docs/android.md, https://ravenkeep.io/docs/ios.md, https://ravenkeep.io/docs/import.md.
