# Ravenkeep documentation

**Connect your product to Ravenkeep.** The SDKs feed one contact record per customer of your product. The **Node SDK** runs on your backend with the secret key; the **browser SDK** runs in your web app with the publishable key, and so does the **Android SDK** in your Android app (from Maven Central), as will the **iOS SDK** in your iOS app once its first version is published. This page explains the concepts they rely on.

- **2 keys**: One publishable and one secret key per project
- **apikey**: The header that carries the key on every request
- **{ data, error }**: Both SDKs resolve to an envelope and never throw

## Which SDK goes where

| Package | Runs in | Key | Sends |
| --- | --- | --- | --- |
| `@ravenkeep/node` | Your backend (Node.js 18+) | `rk_secret_...` | Signups with attribution, identify, product events, revenue, server exceptions, widget user hashes |
| `@ravenkeep/browser` | Your web app | `rk_pub_...` | First-touch attribution, browser errors, page views, the support chat and feature request widget |
| `io.ravenkeep:ravenkeep-android` | Your Android app (Android 5.0+), from Maven Central | `rk_pub_...` | Crashes on the next launch, screens and their engaged time, visits, the events you made public, identify |
| `Ravenkeep` (Swift package) | Your iOS app (iOS 13+); not published yet | `rk_pub_...` | Crashes on the next launch, the screens your app names and their engaged time, visits, the events you made public, identify |

Most products use both: the browser SDK captures where a visitor came from and hosts the widget, and the backend reports the signup, the events and the revenue that only it can see.

### Setting up a project

- Create a project in the console (Settings, then Projects) and pick the currency your revenue is reported in; it can change in Settings, then Projects, then General until the project has revenue or spend. The secret key is shown once: store it in your backend's environment.
- Add the origins of your site and your app under Allowed origins, so the publishable key works there; for an Android or iOS app, add its application id or bundle ID under Apps.
- Copy the identity secret (Identity verification) into your backend's environment, next to the secret key.
- Install the Node SDK on your backend and the browser SDK on your marketing site and your app, from npm (below). The Install tab of the project shows both snippets with your publishable key.
- Report signups, identify customers and track events. The Get started list on the console Overview ticks off the SDK and the first event as they arrive.

### Installing the SDKs

Both SDKs are public packages on npm, with no runtime dependencies. Install each one where it runs:

**npm**

```bash
npm install @ravenkeep/node      # in your backend
npm install @ravenkeep/browser   # in your web app
```

**pnpm**

```bash
pnpm add @ravenkeep/node      # in your backend
pnpm add @ravenkeep/browser   # in your web app
```

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

[Install the browser SDK](https://ravenkeep.io/docs/browser)

[Install the Android SDK](https://ravenkeep.io/docs/android)

[The iOS SDK, not published yet](https://ravenkeep.io/docs/ios)

## Contacts tie it together

A contact is one customer of your product, keyed by your own user id (`userId`). The console lists contacts under **Customers**: a customer in the console is a contact in the API and the SDKs. Events, conversations, votes and emails are attached to a contact, and so is every crash reported with a user id. That is how Ravenkeep shows the customers a crash affects, the revenue behind a feature request, and the campaign that brought a customer in.

- The same `userId` in two projects is two contacts: the project always comes from the key.
- A contact carries an email, a name, traits (your own key and value pairs, such as `plan`), an account (the company or workspace it belongs to), its first-touch attribution and its lifetime value.
- Use a stable id that never changes, not an email address: emails change, ids do not.

## Keys

Each project has exactly one key pair, shown in the console under Settings, then Projects, then Keys. Both travel in the `apikey` header.

| Key | Format | Where | Can |
| --- | --- | --- | --- |
| Publishable | `rk_pub_<slug>_...` | Browser code, Android and iOS apps | Open a widget session, chat, vote, suggest, report errors and crashes, page and screen views and the events you made public. Only from allowed origins and listed apps. |
| Secret | `rk_secret_<slug>_...` | Your servers only | Everything in the ingestion API. Stored hashed: shown once at creation and once at each rotation. |

- The publishable key is public by design: it ships in your pages and apps. It can only do what a visitor may do, and the events and errors it sends are anonymous unless a verified widget session vouches for the user.
- The secret key can write anything for the project. Keep it in your servers' environment, never in a browser bundle, a mobile app or a repository.
- Each SDK refuses the other key: the browser, Android and iOS SDKs never send a secret key, and the Node SDK never sends a publishable one.
- Check a key with `ping()` in the Node SDK: it answers with the project the key belongs to.

> **Rotation is immediate**
>
> Rotating a key invalidates the old one at once, and the new secret is shown only after you rotate, once. Plan a short maintenance window, or rotate and update the environment of every service that uses the key in quick succession: until a service runs the new key, its requests fail with `invalid_api_key`. Rotate the publishable key the same way, then deploy your web app with the new one. An Android or iOS app carries its key: rotating the publishable key stops every installed version of the app until people update to a release with the new key (their events and crashes wait on the device for up to five days).

## Allowed origins

A publishable-key request must send an `Origin` that matches one of the project patterns (Settings, then Projects, then Allowed origins), or it fails with `origin_not_allowed`. Secret-key requests come from your servers and skip this check.

| Pattern | Matches |
| --- | --- |
| `https://app.example.com` | Only `https://app.example.com`. Not `https://www.example.com` nor `http://app.example.com`. |
| `https://*.example.com` | `https://www.example.com` and `https://app.example.com`. Not `https://example.com`, `https://a.b.example.com` nor `https://example.com.attacker.io`. |
| `https://**.example.com` | Subdomains at any depth, such as `https://a.b.example.com`. Not `https://example.com` nor `https://evil-example.com`. |
| `http://localhost:*` | Any local port. Not `http://127.0.0.1:3000`. |

- `*` matches inside one host label or the port; `**` crosses dots. Patterns are origins: a scheme, a host and an optional port, without a path or a trailing slash.
- New projects allow `http://localhost:*` and the origin of their site URL. A project holds up to 50 patterns.
- The allowlist keeps other websites from using your key in their visitors' browsers. It is not authentication: what protects your data is that the publishable key can only do publishable actions, under rate limits.

### Apps

Apps have no origin. A publishable-key request without an `Origin` must name a listed app in the `X-Ravenkeep-App` header (the Android SDK sends `android:<application id>`, the iOS SDK `ios:<bundle id>`), or it fails with `app_not_allowed`. List them in Settings, then Projects, then Apps. A request with an `Origin` is always checked against the allowed origins, whatever else it sends.

- An Android application id or an iOS bundle ID (`com.acme.shop`) matches exactly, with no wildcards: a debug build's `com.acme.shop.debug` is its own entry. A project lists up to 50 apps, and none by default.
- Like the origins, the list keeps your key out of other apps' builds of the SDK. It is not authentication either: an application id or a bundle ID is not secret, so the publishable key's limits are what protect your data.

[Set up the Android SDK](https://ravenkeep.io/docs/android)

[Set up the iOS SDK](https://ravenkeep.io/docs/ios)

## Identity verification

Widget sessions start in the browser with the public key, so a user id alone proves nothing: anyone could claim to be your customer and read their conversations. Identity verification closes that gap. Your backend, which knows who is signed in, signs the user id with the project **identity secret**; the browser passes the signature as `userHash`; Ravenkeep recomputes it and opens the session only when they match.

- The user signs in to your app, and your app asks your backend who they are (for example `GET /api/me`).
- Your backend answers with the user id and `widgetUserHash(userId)`: `hex(HMAC-SHA256(identitySecret, userId))`.
- The browser calls `identify({ userId, userHash })`; the SDK opens a widget session (`POST /v1/widget/session`).
- Ravenkeep compares the hash in constant time. A match opens a verified session for 12 hours, renewed by the SDK while the page is open.

**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, email: req.user.email, ravenkeepUserHash: ravenkeep.widgetUserHash(req.user.id) })
})
```

**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 })
if (error?.code === 'identity_verification_failed') console.warn('Check the identity secret on the server')
```

- Verification is required by default. Without a valid hash, the session fails with `identity_verification_failed` and nothing is written.
- If you turn it off (Identity verification in the project settings), a session without a valid hash still opens, unverified: it never sees the stored profile nor earlier conversations of that customer, only those it started in that browser; its events and errors stay anonymous, and your team sees "Unverified identity" on its messages. An email it sends is kept for replies in its conversations only, never as the customer's address.
- The identity secret is separate from the secret key. Only owners and admins can read or rotate it, and rotating it invalidates every hash at once.

### Which values win

- **Your backend wins.** A name, an email or traits set with the secret key (`signup` and `identify` in `@ravenkeep/node`) are never overwritten from the browser. A browser `identify` only fills a name or an email the contact does not have yet.
- **Only trusted emails get campaigns.** An email set with the secret key, or sent by a verified widget session (a valid `userHash`), can receive campaign emails. An email sent from the browser without identity verification is stored as unverified: your team can still answer in the widget, but campaign flows skip that contact with the reason `untrusted_email`.
- **Campaign emails speak the customer's language.** The unsubscribe footer and page use the contact trait `locale` (`en` or `pt-BR`, set from your backend), else the project's default language (Settings, then Projects, then Email sending).

## Attribution from your site to your app

Attribution answers "which campaign brought this customer?". The browser SDK records the **first touch** of each visitor (UTM parameters, click ids, referrer, landing page) on the first page they open. Your signup form sends it to your backend, and your backend reports it with `signup`. Ravenkeep writes it once on the contact and never overwrites it.

- Load the browser SDK on your marketing site and in your app, with the same publishable key.
- When they live on different subdomains (`www.example.com` and `app.example.com`), set the same `attribution.cookieDomain` (`.example.com`) on both: the first touch recorded on the site is read in the app. A referrer from your own domain counts as internal navigation, not as a source.
- If the signup happens on another domain altogether, forward the UTM parameters in the link to it, or keep the landing URL and parse it on the server with `parseAttribution(url, referrer, { siteHost })`.
- Your signup request carries `getAttribution()`; your backend passes it untouched to `signup`.

**src/signup-form.ts**

```ts
import { Ravenkeep } from '@ravenkeep/browser'

const ravenkeep = Ravenkeep.init({
  publishableKey: globalThis._importMeta_.env.VITE_RAVENKEEP_KEY,
  attribution: { cookieDomain: '.example.com' }, // shared by www. and app.
})

await fetch('/api/signup', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, password, attribution: ravenkeep.getAttribution() }),
})
```

**server/signup.ts**

```ts
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 })
})
```

The Attribution report (Metrics, then Attribution) groups signups, paying customers, revenue, spend, CAC and LTV:CAC by `utm_source`, then by `utm_campaign`. A `signup` campaign flow can filter on the same fields. Signups without a UTM source are grouped under "No UTM source".

[Every attribution field](https://ravenkeep.io/docs/node#signup)

## Events and naming

Events are the moments that matter to your operations team. Name them once, well: the name is what you pick in Metrics, in flow triggers and as campaign goals, and renaming an event later splits its history in two.

- **`object.action`**, lowercase, with the action in the past tense: `ticket.created`, `file.uploaded`, `document.created`, `members.invited`.
- **`snake_case`** for objects of several words: `share_link.created`, `api_key.rotated`.
- **No ids or personal data in names.** `file.uploaded` with `{ sizeMb: 12 }`, never `file.uploaded.8f2c`. A project keeps at most 1,000 distinct event names; past it, events with a new name are refused (`event_name_limit`).
- **Small, categorical properties** you will want to compare: `{ plan: 'free' }`, `{ priority: 'high' }`, a size bucket rather than an exact size.

Send events from your backend with `track` and a `userId`: that ties them to a contact and lets them start flows. Names starting with `$` are Ravenkeep's own (`$signup`, `$revenue`, `$identify`, `$pageview`, `$screen`, and the time spent on a page or screen, `$pageleave` and `$screenleave`). With the publishable key, the browser SDK sends `$pageview` and `$pageleave`, the Android and iOS SDKs `$screen` and `$screenleave` (the Android SDK since 0.1.0; the iOS SDK's first version is not published yet), and all of them only the names in the project's public event names (Settings, then Projects, then the project's Install tab, under Public events); send everything else from your backend.

### Pages and screens

Two reserved events feed the Pages & screens report in Metrics. `$pageview` is a page of a website or web app: the browser SDK sends it with the page's `url`, `path`, `referrer` and `title` (`autoPageviews: true`, or `trackPageview()`), and the report ranks its `path` (when you send `$pageview` yourself, `path` is text, such as `/pricing`; any other type is refused). Ids in a path collapse into one row (`/item/1` and `/item/2` rank as `/item/*`), and so does a segment holding an e-mail address. `$screen` is a screen of a mobile or desktop app: `properties.screen` names the screen and is required (a string of at most 200 characters, trimmed), and the report ranks it as you named it: `#`, `?` and spaces are part of a name, only a `/`-separated segment that is an id collapses. Name screens the way your app's routes are named (`Settings`, `Lesson`, `Item`), not with the id of what they show; send the id as another property.

**screens.ts**

```ts
ravenkeep.track('$screen', { userId: user.id, properties: { screen: 'Lesson', platform: 'android', lessonId: lesson.id } })
```

- **Patterns.** Ids in an address or a screen name become `*`, so `/item/1` and `/item/2` rank as one `/item/*` row: numbers, UUIDs, hex ids of 8 characters or more with a digit, and runs of 16 or more letters and digits mixed. Words and slugs stay (`/blog/top-10-tips`). The query string and the fragment are ignored, and a trailing slash too.
- **Visitors** are people: each identified contact once, and each device without one once, by its anonymous id. A device that identifies during the period counts twice: once for its views before, once as the contact.
- **Unique users** are identified contacts only, as everywhere in Ravenkeep.
- **Platform** filters web pages (`$pageview`) or app screens (`$screen`); a property filter keeps the views whose property has a value, such as `platform` equals `ios`.
- **Time** columns show the engaged time reported for each row in the period (below): its total, and the average per view that reported it. With a property filter they are empty: the time is reported apart from the view, without its properties.

### Time on pages and screens

How long a page or screen held the person is reported as an event when the view ends: `$pageleave` with the page's `path` (the browser SDK sends it since 0.3.0, with `autoPageviews: true`), or `$screenleave` with the screen's `screen`, named as its `$screen` names it (the Android and iOS SDKs send it; any app can send it through the API, and your backend with the Node SDK). Both carry `engaged_ms`, the engaged time in milliseconds: an integer from 0 to 1,800,000 (30 minutes). The browser SDK measures it from 0.3.0, the Android SDK the same way from 0.1.0, and the iOS SDK from its first version, not published yet. An app that sends `$screenleave` itself follows the rules below, so web and apps compare:

- **Engaged** means visible (the page shown, the app in the foreground) and in use: time counts for at most 60 seconds after the view was shown or the person last acted (a tap, a click, a key, the wheel, media playing with sound). A page read for 30 seconds without a click reports about 30 seconds; a hidden tab, an idle screen, a page that scrolls itself or a muted video adds nothing.
- **Segments.** The time since the last report is sent when the next view replaces this one (before that view, so it stays in its visit), when the page is hidden or the app goes to the background, and every 15 minutes while the view holds time. Later segments of the same view carry `resumed: true`, so the views measured are the leave events without it (send it only as `true`: `false` reads as absent, a first segment).
- **Not metered, but bounded.** `$pageleave` and `$screenleave` never count toward your plan's events. A cycle stores at most the larger of 100,000 leave events and 100 per metered event (about 100 times what views send); past it a leave is refused alone, `leave_limit_reached`, and nothing else of its batch is. Where your plan pauses events (Free), a batch's leave events go all or nothing: if the plan refuses any metered event of the batch, or has none left, every leave of the batch is refused with it, `plan_limit_reached`, even those of views that went. A malformed one (a missing or negative `engaged_ms`, a `screen` over 200 characters, properties over 2,048 bytes) is refused alone, `validation_failed`.

**screens.ts**

```ts
ravenkeep.track('$screenleave', { userId: user.id, properties: { screen: 'Lesson', engaged_ms: 42000 } })
```

### Sessions

A session is one visit. Events that carry a `sessionId` belong to that visit: the browser SDK (0.2.0 and later) and the Android and iOS SDKs set one on every event, and the Node SDK's `track` takes the browser's (`getSessionId()`) for a server event of the same visit. For events without one, a person's events with no gap over 30 minutes form a session. Every session needs a page or screen view: server events alone, or an SDK session of custom events only, are not a visit. A session counts on the UTC day it starts and holds events up to the end of the next UTC day.

- **Duration** runs from the first event to the last, so the time spent on the last page or screen counts when it is reported (`$pageleave`, `$screenleave`); Metrics shows the median and the 75th percentile. Events sent with the publishable key are timed when they reach Ravenkeep, so a batch the browser sent late (a tab back from the background) lengthens its visit by that delay, and one held back over 30 minutes (a device asleep or offline) splits it, the 30-minute gap applying within a visit's events too.
- **Visitors** are people: a session's visitor is the latest customer among its events, else the anonymous id it started with.
- **A bounce** is a session with one view that lasted under 10 seconds.
- **Platform** is the kind of the first view: web (`$pageview`) or app (`$screen`).
- **Source and campaign** are the first view's `utm_source`, `utm_medium` and `utm_campaign` properties: the browser SDK 0.2.0 adds the address's `utm_*` to `$pageview`, the Android SDK a deep link's to the automatic screen of the activity it opens, and the iOS SDK those of a link passed to `handleDeepLink` to the current or the next screen; otherwise, on `$screen`, send them yourself. A page view that lands with another campaign starts a new visit (the SDK starts a new `sessionId`; events without one are split by the 30-minute gap only, never by campaign). The report's filters compare each value exactly.
- **Entry and exit** are the first and last view, by pattern, as in Pages & screens.

### Features

A feature is one part of your product, defined in Metrics, then Features, for one project: a name and rules, each a page or screen pattern as Pages & screens shows it (`Patient/*`, `/settings/billing`), matched exactly or as a prefix, or an event name. A visit, by the sessions' rule, uses a feature when it viewed a page or screen, or sent an event, that a rule matches; it counts on the UTC day it starts. Owners and admins define features; everyone in the organization reads them. Nothing changes in your app: mapping screens to features is enough.

- **Visitors** and **users** that used it, and its **adoption**: its visitors among every visitor active in the period.
- **Sessions**, **views** and **events** that used it, and its **engaged time**: the total of its pages and screens, and the median per visitor among those with time.
- **Days per visitor**: the distinct days each visitor used it.
- **Against the period before**: the change in visitors and in engaged time, and the visitors' trend per day, week or month. The API and the MCP's `metrics_features` also give every figure of the period before and the trend of sessions, views and engaged time.

### People across devices

Funnels and retention follow a person from before they sign in to after. A person is a contact. A device's events without a contact become that contact's when an event the report reads carries both the device's anonymous id and the contact: the browser SDK's events after a verified `identify` do, and so does a backend event with both `userId` and `anonymousId`. The earliest such event wins, so on a shared device the anonymous events go to the first contact who signed in on it; that contact's later events, and every other contact's, stay their own. A device never linked counts as one anonymous person. An event with neither a contact nor an anonymous id counts for nobody, and an unverified user id links nothing.

### Funnels

A funnel is a path through your product, defined in Metrics, then Funnels, for one project: 2 to 10 ordered steps, a conversion window from 1 minute to 90 days, and optionally "within one session". A step is an event name, or the page or screen views of a pattern as Pages & screens shows it (`/checkout/*`, `Patient/*`), matched exactly or as a prefix, with up to 3 property filters: the event's property equals a value, compared as Metrics shows values (a string as sent, a number without trailing zeros, so `1.50` is `1.5`; a missing property never matches). Of the `$` names, a step takes only those Ravenkeep stores. Owners and admins define funnels, at most 50 per project; everyone in the organization reads them.

- **Entered** are the people with a step-1 event in the period. A person **reached** step 3 with events of steps 1, 2 and 3 in that order, step 3 within the window of the step 1 it continues; events of the same millisecond go by their id, and one event counts for one step. With "within one session", every step is in one session that starts in the period, by the sessions' rule; a step in a run without a page or screen view, such as a lone server event, does not count.
- **Per step**: the people who reached it, the conversion from step 1 and from the step before, the drop-off, and the median time from the step before (from the latest step-1 start that led to it, so step 1 on Monday and again on Friday, then step 2 a minute later, took a minute).
- **Trend and breakdown**: each person counts in the day, week or month of their first step-1 event of the period, converted or not; a breakdown by a property of that event shows the 10 values with the most people, the rest as other, and no value as its own group.
- **People**: per step, the contacts who reached it or dropped at it (reached the step before, not this one), 50 per page, each linking to their profile; anonymous people are counted, not listed. They are always of the steps the report shows: if someone changes the funnel meanwhile, the report is read again first.
- **Long periods**: a funnel reads at most 16 million stored events of its steps' names, plus at most 100,000 that have just arrived. Past that, the report covers the latest whole days of the period that fit and says from which day, and the trend shows no figures before it; if the last day alone holds more, the fallback store answers from the most recent 500,000 events of the period.
- **Late events**: an event sent with an earlier timestamp, or a sign-in that links a device on an earlier day, can take about 10 minutes to change that day's sessions and links, as in Sessions.

### Retention

Metrics, then Retention groups the people of one project into cohorts by the day, week (from Monday) or month of their first event, or of their first `$signup`, in the history Ravenkeep keeps. A cohort is shown when it starts in the period: a period starting mid-week or mid-month starts at the next whole one, and a person seen before it is in no cohort. At most 60 cohorts: by default, days for a period of up to 60 days and weeks beyond.

- **Retained** in period 1, 2 and on are the cohort's people with a returning event, any event or one you choose, in the day, week or month that many after their cohort's. A period not over yet, or one running past the end of the report's period, is marked incomplete: it counts only part of its days.
- **The curve** is, per period, the people retained over the size of the cohorts whose period is over; where none is, the point is empty, never 0.
- **Breakdown** by a property of the cohort's event: each value's size and curve, the 10 largest values, the rest as other, and no value as its own group.
- **Long histories**: a report reads at most 650 million stored events (its history, and with a breakdown the cohorts' period twice), plus at most 100,000 that have just arrived. Past that, the fallback store reads only the most recent 500,000 events of the history, which cannot tell who is new in a cohort: the report then shows no figures and says why.

### What events power

| Where | How it uses events |
| --- | --- |
| Metrics, Events | Hourly or daily series (UTC), totals and unique contacts for up to three events side by side, and a breakdown by one property over any period of your plan's history (up to a year). Every name appears in the event list after its first occurrence. |
| Metrics, Pages & screens | The most viewed pages (`$pageview`) and app screens (`$screen`) of any period of your plan's history, grouped by pattern, with views, visitors, unique users, the change against the period before and a trend. |
| Metrics, Sessions | Sessions, visitors, duration, views per session and bounce rate of any period of your plan's history, with a trend, the period before, and the sessions by platform, source, campaign, entry page and exit page. |
| Metrics, Features | Per feature you define (pages, screens and events of one part of your product): visitors, users, adoption, sessions, views, events, engaged time, days per visitor, with the change against the period before and a trend. |
| Metrics, Funnels | Per funnel you define (2 to 10 steps): the people who reached each step, the conversion and drop-off, the median time between steps, a trend, a breakdown by a property, and the contacts who reached or dropped at each step. |
| Metrics, Retention | Cohorts by first event or first signup per day, week or month, the share of each that returned in the periods after, the curve, and a breakdown by a property. |
| Metrics, Overview | Active users (DAU, WAU, MAU) count the contacts with at least one event that day. `$revenue` does not count as activity. |
| Campaign triggers | An `event` flow starts when a contact sends that event, optionally only when its properties match a filter (for example `plan` equals `free`). |
| Flow conditions | `hasEvent` branches on whether the contact sent an event within the last N days (up to 180). |
| Campaign goals | A flow's goal event counts a conversion when a contact who got one of its emails sends that event within 7 days. |

[Track events with the Node SDK](https://ravenkeep.io/docs/node#track)

## Crash grouping

The SDKs report exceptions with their stack. Ravenkeep computes a **fingerprint** for each occurrence on the server, and occurrences with the same fingerprint, in the same project and environment, form one crash group. The fingerprint is built in this order:

- **Your fingerprint.** When the report carries `fingerprint` (an array of strings), it alone decides the group.
- **Type and top frames.** Otherwise the exception type and the top 5 in-app frames, as module and function, with line and column numbers, query strings, build hashes (`index-3f9a8c.js` becomes `index.js`), absolute path prefixes and numbered directories removed. When no frame is in-app, all frames are used.
- **Message added.** When the top frame has no function name (a minified bundle), or every frame is native, the normalized message joins the input so different bugs in the same anonymous code do not collapse into one group.
- **Type and message.** Without usable frames, the type and the message with numbers, ids, emails, quoted strings and URLs replaced by placeholders.

**Java and Kotlin traces** (the Android SDK sends the whole trace) group by their root cause: the innermost `Caused by:` with a frame of your app, so a framework wrapper such as `Unable to start activity` groups by what caused it. Line numbers, lambda and synthetic class counters and the SDK's own frames are left out, so the same bug groups alike across releases. Without an R8 mapping (not uploaded yet), an obfuscated release groups by its type, its readable frames and its message. The title stays the thrown exception's.

**Swift and Objective-C stacks** (the iOS SDK sends the crashing thread's frames, each with its binary image) group by their in-app functions, as image and function. Where an exception or a trap is raised (`objc_exception_throw`, `abort`, the Swift runtime's failure paths) is left out, and so are closure numbers, specializations, thunks, parameter and return types, line numbers and addresses, so the same bug groups alike across releases. A stripped release build has no names for its own frames on the device (dSYMs are not uploaded yet): it groups by its type (`SIGSEGV`, an `NSException`'s name, a Swift error's type), its system frames and its message. The title is the type and the first line of the message.

**server/payments.ts**

```ts
// Every payment provider timeout in one group, whatever the stack
ravenkeep.captureException(error, {
  userId,
  fingerprint: ['payment-timeout', provider],
})
```

### Environments, releases and resolutions

- Groups are separate per `environment` (lowercased, `production` by default), so staging noise never hides a production crash.
- Set `release` to your version or commit. Each group records the release where it was first and last seen.
- **Resolve** marks it fixed in the next release: it regresses only on an occurrence from a release newer than the last one it saw.
- **Resolve in release X** regresses on X or anything newer. **Resolve now**, for code without releases, regresses on any later occurrence.
- Releases compare as versions (`v1.2.10` is newer than `v1.2.9`, pre-releases in semver order); names that are not versions compare as different or equal. Occurrences that happened before the resolution never regress it.
- A regressed group is flagged and notifies your team. **Ignore** keeps a group quiet for good, and **Merge** folds groups the fingerprint kept apart into one.

Each group shows its impact: affected customers and their share of the customers active in the last 30 days, affected accounts, the revenue at risk (the lifetime value of the affected contacts), the trend of the last 24 hours and 30 days of events. Crashes reported without a user id count as events but affect no one by name.

## Support chat and feature requests

Once a signed-in customer is identified, the browser SDK shows a launcher that opens the widget. It renders in a Shadow DOM, speaks English and Brazilian Portuguese, and has two tabs.

| Tab | For your customer | For your team |
| --- | --- | --- |
| Chat | A realtime conversation with attachments, typing indicators and read receipts. | The console Inbox (and the Android app): replies, internal notes, saved replies, assignment, priority and status, with the customer's plan, LTV, first touch and crashes beside the thread. |
| Feature requests | The requests you published, with their status, search, upvotes and a form to suggest one. | Feature requests in the console: suggestions to accept, merge or decline, statuses from under review to shipped, and each request's voters with their accounts and lifetime value. |

- Suggestions stay private until your team accepts, merges or declines them. Merging moves the customer's vote to the request it joins.
- From a conversation, your team can add the customer's vote to a request in one step.
- When a request changes status, a `featureRequestStatus` campaign flow can email its voters.
- Without `identify` there is no widget: anonymous visitors cannot chat or vote.

[Embed the widget](https://ravenkeep.io/docs/browser#widget)

## Campaign triggers

Campaigns are email flows built in the console's visual editor: a trigger, then waits, conditions, A/B splits, emails, contact updates and webhooks. Each flow has one trigger, and the trigger decides which SDK data it needs.

| Trigger | A contact enters when |
| --- | --- |
| `event` | It sends the named event, optionally only when the event's properties match a filter. Fed by `track(name, { userId, properties })` from the Node SDK. |
| `signup` | It signs up for the first time, optionally only for some attribution values (such as `utmSource`). Fed by `signup({ userId, email, attribution })`. |
| `featureRequestStatus` | A request it voted for moves to a status (optionally one request only). Fed by votes from the widget and your team's status changes. |
| `manual` | An operator enrolls it, picked by hand or by a filter: up to 5,000 per request and 20,000 per flow and hour. |

- Re-entry is `never` by default for `event`, `signup` and `manual` flows, and `always` for status changes; it can also be once every N days. A contact is never in the same flow twice at the same time.
- Emails go only to contacts with a trusted email address (set with the secret key, or from a verified widget session) who did not unsubscribe; a contact whose only address came from an unverified browser session skips the email with the reason `untrusted_email`. Every email carries an unsubscribe link and the `List-Unsubscribe` header, in the contact's `locale` trait or the project's default language; opens and clicks are tracked per step.
- Published flows are versioned: contacts already in a flow finish the version they started.
- Campaign emails go out through your own email provider, from your own address: connect your Resend account with an API key in the project's Email sending settings (Resend's free plan sends up to 3,000 emails a month, 100 a day). You verify your domains in Resend, and Ravenkeep stores the key encrypted. Until a provider is connected, a flow that sends email cannot be published, and Ravenkeep's own address only sends test emails, each to the person on your team who asks for it, once their sign-in address is verified.

## Limits and rate limits

Past a rate limit the API answers `429 rate_limited` with a `Retry-After` header; the SDKs wait and retry their batches. Each route family (events, signups, crashes, widget) has its own budget, so a burst of events never delays a signup. The limits are per project unless the table says otherwise.

| Rate | Limit |
| --- | --- |
| Events | Secret key, `POST /v1/events` and `GET /v1/ping`: 200 requests per second, bursts of 400 |
| Signups | Secret key, `POST /v1/signup`, `/v1/identify` and `/v1/revenue` together: 100 requests per second, bursts of 200 |
| Crashes | Secret key, `POST /v1/crashes`: 100 requests per second, bursts of 200. Crashes themselves: 2,000 per second with the secret key and 400 per second with the publishable key; from browsers, at most 100 new crash groups per hour |
| Publishable key | Per route family: 20 requests per second per visitor IP address (bursts of 40), and 200 per second for the whole project (bursts of 400) |
| Widget | Messages, votes, suggestions and uploads: 10 per minute each, per contact |
| Invalid keys | After 60 failed key lookups in a minute from one address, answers become `429` |

| Payload | Limit |
| --- | --- |
| Request body | 1 MiB |
| Batch | 500 events, or 50 crashes |
| Event | Name up to 100 characters, properties up to 32 KiB (2,048 bytes for `$pageleave` and `$screenleave`), `messageId` deduplicated for 7 days |
| Event names | 1,000 distinct names per project; a new name past it is refused with `event_name_limit` |
| Event time (secret key) | Up to 366 days in the past (or the plan’s history, when shorter) and 1 day ahead; publishable-key events are stamped with the time they arrive |
| Contact | Ids up to 255 characters, traits up to 32 KiB per call and 64 KiB in total |
| Crash | Stack up to 256 KiB, 250 frames, 50 breadcrumbs, 50 tags, context up to 64 KiB |
| Revenue | Up to 10^12 minor units per record |
| Attachments | 10 MB each, 10 per message, and 2 GiB per project in any 24 hours |
| Allowed origins | 50 per project |
| Apps | 50 per project |

## Data retention

Ravenkeep keeps what reports need and compacts the rest on a daily schedule. Deleting a project deletes its data.

| Data | Kept |
| --- | --- |
| Contacts | Profiles, first-touch attribution and lifetime value, until the project is deleted. Attribution is never overwritten. |
| Raw events | 366 days, or the plan’s history when shorter. Older events are dropped once their daily counts exist; the daily counts stay. |
| Event counts | Hourly counts for 35 days; daily counts and unique contacts for good. |
| Active contacts | Per contact and day for 400 days, then as daily totals (DAU, WAU, MAU) for good. |
| Crash occurrences | Every occurrence of the last 30 days, and the newest 100 of each group beyond. Group counters and history stay. |
| Support | Conversations, messages, feature requests and votes, until they or the project are deleted. Attachments never sent in a message are removed after 24 hours. |
| Campaign runs | Step logs for 90 days after a run ends, finished runs and individual clicks for 180 days. Per-step counters stay. |
| Notifications | Your team's read notifications for 90 days, and at most the newest 2,000 per person in each organization. |
| Deduplication ids | 7 days. |

---

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