# Installing RevealWhy

The canonical install guide for the RevealWhy browser SDK, written against the binding
integration contract (`docs/INTEGRATION_CONTRACT_2026-09-19.md`, "SDK v1"). If anything here
disagrees with the contract, the contract wins.

- Script: `https://app.revealwhy.com/sdk/v1/recorder.min.js` (versioned, immutable per version)
- Endpoint: `https://app.revealwhy.com`
- Key: your project's **publishable** key (Settings → Tracking code; the Project ID is shown there too)

> **Fastest path — let Claude Code do it.** With the plugin installed
> (`/plugin marketplace add RevealWhyApp/revealwhy-claude-plugin`, then `/plugin install revealwhy@revealwhy`),
> open the site's repository and say *"add RevealWhy analytics"*. The `revealwhy-install` skill detects the
> framework and the existing consent banner, writes one consent-gated loader, wires the key product events,
> documents them in `CLAUDE.md` and tells you the one env var to set. Everything below is what it does.

> **Is it working?** Open your live site with `?rw_debug=1` at the end of the address. A badge on the page
> says whether this visit is recording and, if not, why (§8). `window.RevealWhy.status()` returns the same.

---

## 1. Keys: publishable vs secret

| | Publishable key | Secret key |
|---|---|---|
| Looks like | project `api_key` (UUID) or `rw_pub_…` | `rw_…` (scoped: `read:findings`, `read:analytics`) |
| Where it goes | your page source / SDK snippet, so it is public | server-side only (env var, secret manager) |
| Can do | ingest sessions/events for its own project, from allowed origins; read the SDK config | read the `/api/v1` API (findings, analytics, **session replay**), the MCP server |
| Can't do | read any data | ingest, mutate, or be used in a browser |

Never put a secret key in a page, a mobile bundle, or a public repo. If one leaks, revoke it in
Settings → Developers → Secret API keys. The API reference is `docs/openapi.yaml` (base URL `https://api.revealwhy.com`).

---

## 2. Snippets

All variants load the same script. You can configure it either with **`data-` attributes on the
script tag** (no inline script, so no CSP exception) or with a **`window.RevealWhyConfig` global**
set before the script loads. If both are present, the global wins.

### 2a. Plain HTML (recommended; CSP-friendly)

```html
<!-- before </body> -->
<script async
  src="https://app.revealwhy.com/sdk/v1/recorder.min.js"
  data-api-key="YOUR_PUBLISHABLE_KEY"
  data-api-endpoint="https://app.revealwhy.com"
  data-mask-mode="strict"
  data-exclude-paths="/account/*,/checkout/**"></script>
```

#### Script-tag attributes (SDK v1)

Every attribute below is public and stable for SDK v1. Each maps to the `window.RevealWhyConfig` key in brackets;
when both are set, the global wins. A **boolean** attribute is true when present with no value, `"true"` or `"1"`;
any other value is false. A **list** is comma-separated, with spaces around items ignored.

| Attribute | Type | Meaning |
|---|---|---|
| `data-api-key` [`apiKey`] | string, **required** | The project's **publishable** key (`rw_pk_…`). Never a secret key. |
| `data-api-endpoint` [`apiEndpoint`] | URL | Where events go. Default: the script's own origin when it loads from another origin, else the page's origin. Set it to the RevealWhy origin you install from (`https://app.revealwhy.com`). |
| `data-require-consent` [`requireConsent`] | boolean | Nothing is stored or sent until consent is granted (§3). |
| `data-has-consent` [`hasConsent`] | boolean | The visitor has **already** consented, so recording starts immediately even with `data-require-consent`. Use it only when the tag is injected after consent (§3, consent-first loader). |
| `data-mask-mode` [`maskMode`] | `strict` \| `standard` | How replay text is masked (§5). Default `strict`; the project's server setting wins when it is stricter. |
| `data-exclude-paths` [`excludePaths`] | list of globs | Paths where recording pauses (`*` = one segment, `**` = any depth) (§5). Unioned with the project's server list. |
| `data-url-token-patterns` [`urlTokenPatterns`] | list | Path patterns whose `:token` segments are replaced before any URL leaves the browser, e.g. `/share/c/:token` (§5 URLs). Unioned with the server list. |
| `data-url-query-allowlist` [`urlQueryAllowlist`] | list | Query parameters kept on URLs (default: the `utm_*` set, `gclid`, `ref`) (§5 URLs). |
| `data-debug` [`debug`] | boolean | Lets the SDK log to the browser console (it is silent otherwise). The on-page status badge is separate: it is shown by `?rw_debug=1` (or `#rw_debug`) in the page URL (§8). |
| `data-consent-tiers` [`consentTiers`] | `on` / boolean | Consent tiers, behind the server switch (§3, Consent tiers). |
| `data-fallback-banner` [`fallbackBanner`] | boolean | Allow the SDK's small fallback consent banner (§3, Consent tiers). |
| `data-study` | `off` | This copy never enters user-testing study mode (for a site measuring itself that also serves study participants). Only `off`, `false` or `0` count. |

`data-project-id` is accepted for older snippets and is not needed: the key identifies the project.

### 2b. Next.js App Router with a nonce CSP

Assumes your `middleware.ts` generates a nonce, sends it in the CSP header, and forwards it as the
`x-nonce` request header (the standard Next.js pattern).

```tsx
// app/layout.tsx
import Script from 'next/script';
import { headers } from 'next/headers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? undefined;
  const key = process.env.NEXT_PUBLIC_REVEALWHY_KEY; // set per environment at build time
  return (
    <html lang="en">
      <body>
        {children}
        {key && (
          <Script
            src="https://app.revealwhy.com/sdk/v1/recorder.min.js"
            strategy="afterInteractive"
            nonce={nonce}
            data-api-key={key}
            data-api-endpoint="https://app.revealwhy.com"
            data-require-consent="true"
            data-exclude-paths="/verify/*,/embed/**"
          />
        )}
      </body>
    </html>
  );
}
```

(On Next 14, `headers()` is synchronous: `headers().get('x-nonce')`.)

**Alternative:** the typed helper package `@revealwhy/browser` (`packages/revealwhy-browser` in
this repo; **not yet published to npm**) gives you `<RevealWhy config={{ apiKey, nonce }} />`
and `useRevealWhy()`. It only loads the same versioned script. See its README.

### 2c. Vite / React SPA, with identify on login

Load conditionally from code (not `index.html`) so you decide which builds and routes get it:

```ts
// src/revealwhy.ts
declare global { interface Window { RevealWhyQueue?: unknown[][]; RevealWhyConfig?: object } }

export function loadRevealWhy() {
  const apiKey = import.meta.env.VITE_REVEALWHY_KEY;
  if (!apiKey || typeof window === 'undefined') return;
  window.RevealWhyQueue = window.RevealWhyQueue || [];
  window.RevealWhyConfig = {
    apiKey,
    apiEndpoint: 'https://app.revealwhy.com',
    requireConsent: true,
    excludePaths: ['/share/c/*', /^\/embed(\/|$)/],
  };
  const s = document.createElement('script');
  s.async = true;
  s.src = 'https://app.revealwhy.com/sdk/v1/recorder.min.js';
  document.head.appendChild(s);
}

const rw = (...cmd: unknown[]) => { (window.RevealWhyQueue = window.RevealWhyQueue || []).push(cmd); };
export const onLogin = (user: { id: string; plan: string; role: string }) =>
  rw('identify', user.id, { plan: user.plan, role: user.role });
export const onLogout = () => rw('reset');
```

```ts
// src/main.tsx
import { loadRevealWhy } from './revealwhy';
loadRevealWhy();
```

Commands pushed onto `RevealWhyQueue` before the script has loaded are replayed in order once it
loads. After that, `push` runs them immediately, so the same call works at any time.

### 2d. Server-rendered templates (Python / Jinja-style)

```jinja
{# templates/base.html #}
{% if config.REVEALWHY_KEY and not exclude_revealwhy %}
<script async
  src="https://app.revealwhy.com/sdk/v1/recorder.min.js"
  data-api-key="{{ config.REVEALWHY_KEY }}"
  data-api-endpoint="https://app.revealwhy.com"
  data-require-consent="true"
  data-url-token-patterns="/console/verify/:token"></script>
{% endif %}
```

Set `exclude_revealwhy = True` in the context of pages that must never load the recorder (camera,
verification or token pages). Multi-page sites work: each page load resumes the same session.

### Legacy aliases (still work, don't use for new installs)

- Script URLs `/insight-recorder-enhanced.js`, `/insight-recorder.js`.
- Global `window.InsightFlowConfig` (read if `RevealWhyConfig` is absent).
- Config key `apiUrl` (use `apiEndpoint`).
- `InsightFlow.init({...})` is **not** an API. Older guides showed it; it never existed on the
  current recorder.

---

## 3. Consent

With `requireConsent: true` (or `data-require-consent="true"`), the SDK stores nothing and sends
nothing until consent is granted.

```js
window.RevealWhyQueue = window.RevealWhyQueue || [];

// wire to your CMP / cookie banner
cmp.on('accept', () => RevealWhyQueue.push(['consent', true]));   // starts recording
cmp.on('decline', () => RevealWhyQueue.push(['consent', false])); // stops, clears storage

// persistent user choice (e.g. a privacy settings page)
RevealWhyQueue.push(['optOut']); // stops now, clears visitor id, persists rw_optout=1
RevealWhyQueue.push(['optIn']);
```

If the user has already accepted, set `hasConsent: true` in the global config (or `data-has-consent="true"`).
Do Not Track and Global Privacy Control are honoured.

#### Consent-first loader (load the script only after Accept)

Some sites must not load any third-party script before consent. Inject the tag from your banner's *accept*
handler, and say that consent is already given:

```html
<script>
  function loadRevealWhy() {
    if (document.querySelector('script[data-rw-loader]')) return; // once per page
    var s = document.createElement('script');
    s.async = true;
    s.src = 'https://app.revealwhy.com/sdk/v1/recorder.min.js';
    s.setAttribute('data-rw-loader', '');
    s.setAttribute('data-api-key', 'YOUR_PUBLISHABLE_KEY');
    s.setAttribute('data-api-endpoint', 'https://app.revealwhy.com');
    s.setAttribute('data-require-consent', 'true'); // stays safe if the tag is ever loaded another way
    s.setAttribute('data-has-consent', 'true');     // this tag is only ever injected after Accept
    document.head.appendChild(s);
  }
  cmp.on('accept', loadRevealWhy);
  if (cmp.hasAccepted()) loadRevealWhy();            // a returning visitor who accepted earlier
  cmp.on('decline', function () { (window.RevealWhyQueue = window.RevealWhyQueue || []).push(['consent', false]); });
</script>
```

The inline script needs a CSP nonce or hash, or can live in a small first-party `.js` file. A later decline still
works through the queue: `['consent', false]` stops recording and clears storage.

### Consent tiers (SDK 1.1, behind the server switch `CONSENT_TIERS`)

Contract: `docs/CONSENT_TIERS_CONTRACT_2026-09-19.md`. Nothing below changes anything until the server runs with
`CONSENT_TIERS=on` **and** the visitor's region default is enabled (`consent_region_policies`); then
`GET /api/sdk/config` carries a `tierPolicy` and the SDK works in tiers:

| tier | what | storage |
|---|---|---|
| 2 | today's recording (masked replay, trackers), from the moment of consent — nothing earlier is replayed | first-party |
| 1 | page-level signals only (page view, scroll depth, device class, referrer host), one batch per page to `POST /api/signals` | **none** (no cookies, Web Storage, IndexedDB or Cache API; an in-memory page id) |
| 0 | nothing | none |

- Installs **without** `requireConsent` pick the policy up with no snippet change (they already fetch `/api/sdk/config`).
- Installs **with** `requireConsent` (or visitors with GPC / DNT) make no request before the choice unless the snippet
  opts in: `consentTiers: true` (`data-consent-tiers="on"`). With the opt-in the SDK asks for the policy (nothing is
  stored), sends tier-1 signals where the region allows, and reads the consent manager itself; without a policy it
  behaves exactly as v1.
- Consent managers read automatically: IAB TCF v2.2 (`__tcfapi`: analytics = purpose 1 and any of 7/8/9, replay =
  purposes 1, 8 and 9), Google Consent Mode v2 (`analytics_storage`), IAB GPP (`__gpp`: `tcfeuv2` as TCF, US sections'
  sale/sharing opt-outs and Gpc as a replay opt-out), OneTrust (C0002), Cookiebot (statistics), Usercentrics (a
  "RevealWhy" service, else the statistics category), Didomi (the measurement purpose), CookieYes (analytics), Osano
  (ANALYTICS), Termly (analytics), Complianz (statistics). Override a vendor's category with
  `consentCategories: { onetrust: 'C0004', … }`. The glue snippets keep working (they call `consent(true|false)`).
- Manual API: `RevealWhy.consent.grant({ analytics?, replay? })`, `RevealWhy.consent.revoke()`,
  `RevealWhy.consent.state()` (tier, analytics, replay, source, regionPolicy, decidedAt, gpc) and
  `RevealWhy.consent.onChange(cb)`. `RevealWhy.consent(true|false)` and `RevealWhyQueue.push(['consent', …])` still
  work. Withdrawal drops to tier 1 at once, sends nothing more and clears RevealWhy's storage and ids.
- GPC / DNT cap the tier at 1 (an opt-out of replay). `fallbackBanner: true` (or the project's policy) shows a small
  banner on sites without a consent manager; off by default.
- Owner-operated capture (the extension, the in-app tester with `source: 'tester'`, synthetic twins) and study sessions
  never read the site's consent manager and always record in full.

---

## 4. Identify, groups and custom events

| Call | Notes |
|---|---|
| `identify(userId, traits?, { rawId? })` | `userId` is hashed in the browser as `sha256(projectId + ':' + userId)` unless `{ rawId: true }`. Traits allowlist: `plan, role, account_id, account_name, signup_date, locale, tier, is_internal` (string/number/boolean only; `account_name` is hashed; others are dropped). |
| `group(accountId, traits?)` | Same hashing. Traits allowlist: `plan, tier, size, industry`. |
| `track(name, props?)` | Custom event. Flat props, at most 20 keys, string/number/boolean; strings are scrubbed. |
| `reset()` | Call on logout: clears identity and starts a new session. |
| `endSession()` | Explicit end. The SDK does not end the session on page hide; idle sessions settle after 30 min. |
| `sessionId`, `version` | Read-only properties on `window.RevealWhy`. |
| `status()` | Why this page is or is not recording: `{ state, reason, message, sessionId, httpStatus }` (§8). |
| `getSessionId()` | The current session id, or `null` while this page is not recording (SDK 1.3.5). |
| `recentErrors(n = 5)` | The last `n` (max 20) distinct uncaught errors on this page, newest last: `{ kind, message, source, line, col, count, ts }`, already scrubbed. `[]` while not recording (SDK 1.3.5). |

**The queue.** `window.RevealWhyQueue` (an array; create it if absent) accepts exactly these commands, as
`RevealWhyQueue.push([name, ...args])`: `consent`, `optIn`, `optOut`, `identify`, `group`, `track`, `reset`,
`endSession`. For example `RevealWhyQueue.push(['track', 'plan_upgraded', { from: 'free' }])` or
`RevealWhyQueue.push(['consent', true])`. Commands pushed before the script loads run in order once it has loaded;
after that, `push` runs them at once. Any other name is ignored, and a failing command never breaks the page.

**Direct calls.** `getSessionId()`, `recentErrors(n)` and `status()` return a value, so they are called on
`window.RevealWhy`, never through the queue. `window.RevealWhy` is set when the SDK initialises, however the tag
was loaded (a plain tag, the data attributes, or a consent-first loader), as long as the tag carries a key. That
happens at once for a tag injected after the page has loaded, and by `DOMContentLoaded` otherwise. Before then
it is undefined, so check it first: `window.RevealWhy?.getSessionId?.() ?? null`. On a visit that is not recorded
it is an inert object whose calls record nothing and whose `getSessionId()` returns `null`.

Never queue a `track` from before consent: a loader that injects the script only after consent should drop
events until the script exists.

### Linking to a session

To link one of your users (a support agent, a researcher) to the recording of a visit, use the public session
link. Build it from the session id (`RevealWhy.getSessionId()` in the browser, `rw_session_id` on a REP attempt):

```
https://app.revealwhy.com/link/v1/sessions/<session id>
https://app.revealwhy.com/link/v1/sessions/<session id>?study=<study id>   (opens it filtered to a study)
```

URL-encode the id. Use your own RevealWhy app origin if you are on a dedicated deployment.
- **Stable.** The `v1` link answers with a redirect to wherever the dashboard keeps the session today. Dashboard
  routes may change; a `v1` link keeps redirecting for as long as v1 is supported, and any retirement is announced
  in `docs/SDK_CHANGELOG.md` well ahead. Do not link to `/app/...` routes directly: they are not a contract.
- **Grants nothing.** The link carries only the id. Whoever opens it signs in to RevealWhy and sees the recording
  only if they have access to that project. The redirect reads no data, so it cannot tell anyone whether a session
  exists, and it sends `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.

Finding evidence (`GET /api/v1/projects/{projectId}/findings/{findingId}/evidence`) returns the same link for each
exemplar as `exemplarSessionUrls`, and REP attempts carry it as `rw_replay_url`.

### JavaScript errors (SDK 1.3.5)

While recording, the SDK listens for uncaught errors (`error`) and unhandled promise rejections
(`unhandledrejection`). Each distinct error is sent once per page as a `js_error` custom event and shown on the
visit in the dashboard. Failed image or script loads are not counted. Privacy rules:
- **Messages and stack frames are scrubbed** like any other text (e-mail, phone, card, token…), and
  script URLs are normalized like page URLs (query dropped, `urlTokenPatterns` applied).
- **In `strict` mask mode, quoted literals** in a message are masked (`'***'`).
- **A rejection with a non-Error value** is described by its type only, never serialized.
- **Stacks keep 5 frames.**

Limits:
- **Repeats** only bump a count.
- **At most 10 errors are sent per page and 50 per visit.**

### Event vocabulary

Pageviews, clicks and scrolling are captured automatically. Custom events are what tell RevealWhy what
*success* means for your product, so wire the 3–8 moments that matter, after they succeed. Use these
names when they fit (snake_case, object + past-tense verb), so findings and goals read the same across sites:

| Event | Props |
|---|---|
| `cta_clicked` | `target`, `location` |
| `outbound_clicked` | `target_host`, `location` |
| `form_submitted` | `form`, `success` |
| `signup_completed` / `login_completed` | `method` |
| `onboarding_step_completed` | `step`, `index` |
| `plan_upgrade_started` / `plan_upgraded` | `plan` |
| `content_downloaded` | `resource` |
| your product's key moments, e.g. `assistant_created`, `channel_published`, `export_completed` | small, non-personal |

No emails, names, phone numbers, free text or ids of people in props.

---

## 5. Privacy controls

### Masking attributes

| Attribute | Alias | Effect |
|---|---|---|
| `data-rw-block` | `.rr-block` | Element not recorded; replay shows a placeholder box; no text from it or its descendants is sent by any tracker. |
| `data-rw-mask` | `.rr-mask` | Text replaced by a fixed `***` (not length-preserving). |
| `data-rw-unmask` | — | In `strict` mode, allow this element's text. Use only on non-sensitive UI. |
| — | `.rr-ignore` | No input events recorded. |

```html
<section data-rw-mask>{{ customer.name }} · {{ customer.email }}</section>
<div data-rw-block><video id="camera"></video></div>
<nav data-rw-unmask>…</nav>
```

**Mask modes:**
- `strict` (default): all replay text is masked. Trackers send text only for buttons, links,
  navigation, headings and labels (after the scrubber), or for `data-rw-unmask` elements.
- `standard`: text is visible except masked/blocked elements. The scrubber still applies.

Inputs are always masked, with a fixed-length mask. A scrubber runs in the browser and again on the
server. It replaces emails, phone numbers, card numbers (Luhn), IBANs, passport-like ids, JWTs,
`sk_`/`pk_`/`rw_` keys and long hex/base64 tokens with `[email]`, `[phone]`, `[card]`, `[iban]`,
`[id]`, `[token]`.

### Excluded paths

`excludePaths` takes globs (`*` = one path segment, `**` = any depth). RegExp values work only in
the global config; `data-exclude-paths` takes a comma list of globs. On an excluded path, recording
pauses and every tracker stops. At most one `page_view` is sent, marked `excluded: true`, with its
path replaced by `[excluded]`. Recording resumes when the visitor leaves the path.

Excluding a path is the second line of defence. The first is not loading the SDK at all on
sensitive pages (see §2c/§2d).

### URLs

Every URL, referrer and href that leaves the browser is reduced as follows:
- Query parameters are dropped, except those in `urlQueryAllowlist` (default `utm_source`,
  `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `gclid`, `ref`).
- Fragments are dropped.
- Path segments matching `urlTokenPatterns` are replaced. For example, with `/share/c/:token`,
  `/share/c/abc123` becomes `/share/c/:token`.

The server applies the same rules again when data arrives.

---

## 6. Content Security Policy

```
script-src  'self' https://app.revealwhy.com;
connect-src 'self' https://app.revealwhy.com;
```

- If you use the `data-` attribute form, you need no `'unsafe-inline'`, nonce or hash for the config.
  The `RevealWhyConfig` global form needs a nonce or hash for its inline script.
- No third-party CDN is needed: `/sdk/v1/recorder.min.js` carries rrweb inside it. (Older docs asked for
  `https://cdn.jsdelivr.net`; that is no longer true and can be removed from your CSP.)

---

## 7. Gated / logged-in app checklist

1. Separate **prod and staging projects**. Take the key from the build or runtime environment and
   never hard-code it. List every origin (e.g. `app.example.com`, `example.com`, staging hosts) in
   the project's allowed origins.
2. Wire a consent banner to `RevealWhy.consent(true|false)` with `requireConsent: true`. Collect
   consent before any non-essential storage.
3. Load the SDK conditionally in code (first line of defence) **and** set `excludePaths` for widget,
   token, camera and verification routes (second line).
4. Put `data-rw-mask` on PII containers (names, emails, addresses, chat transcripts, records). Put
   `data-rw-unmask` only on non-sensitive UI chrome, and only if `strict` hides too much.
5. CSP: add `https://app.revealwhy.com` to `script-src` and `connect-src` (§6).
6. After login, call `identify()` with a hashed id plus `plan`/`role` traits. Call `reset()` on
   logout.
7. Update your privacy policy and subprocessor list.
8. Verify on staging first: sessions arrive, excluded routes send nothing, masked text never
   appears in replay, and consent-declined visitors produce no requests. Only then use the
   production key.

---

## 8. Troubleshooting: "no visits yet"

Open the live site with `?rw_debug=1` (or `#rw_debug`) and read the badge in the bottom-left corner. It is
shown only in that browser, sends nothing, and never appears in a replay.

| Badge says | Meaning | Fix |
|---|---|---|
| waiting for consent | The snippet requires consent and the page never called `RevealWhy.consent(true)`. | Wire your banner's *accept* to `RevealWhyQueue.push(['consent', true])` (Settings → Tracking code has glue for common consent managers). The badge's button grants consent for this one test visit. |
| blocked privacy signal | This browser sends Global Privacy Control or Do Not Track (Brave and DuckDuckGo do by default). Such visitors are never recorded. | Test in another browser. Nothing to fix on the site. |
| rejected · origin not allowed | The page's address is not the project's website or one of its allowed origins (staging, a second domain, localhost). | Settings → Privacy and data → Allowed origins. |
| rejected · unknown key / secret key | The key in the snippet is not this project's publishable key. | Re-copy it from Settings → Tracking code. |
| rejected · session limit | The plan's monthly session limit is reached, or the project has no plan yet. | Settings → Plan and usage. |
| could not reach RevealWhy | Offline, an ad blocker, or a CSP `connect-src` without the RevealWhy origin. | §6. |
| recording | It works. | Visits show within a minute; pages and heatmaps about 30 minutes after a visit ends; findings once ~10 visits are processed. |

No badge at all means the script is not on the page: the loader did not inject it (no key in the build, the
route is excluded, consent was never given), or a CSP `script-src` blocked it.

