# Setup guide for coding agents

> One guide for any coding agent that installs Whisperr in an app. Connect the Whisperr MCP server first, sign-in included; install without MCP only as a fallback.

This guide is for the coding agent that installs Whisperr in an app, and for the developer who reviews the change. It works in any agent: Claude Code, Cursor, Windsurf, Codex, Replit, Lovable, Bolt, Rork and others. Follow it from top to bottom. The plain-text version is `https://docs.whisperr.net/setup.md`.

When you finish, the app sends who each user is (`identify`) and what they do (`track`) to Whisperr, and the user sees those events arrive in the Whisperr dashboard.

## Read the prompt you were given

The Whisperr dashboard gives the user a short prompt to paste into the agent. It starts with the first line below. Each other line is there only when Whisperr knows the value. For example:

```text
Install Whisperr in this app: follow https://docs.whisperr.net/setup.md
Whisperr publishable key: wpk_…
Platform: Expo
Email permission: Per user
Main action in this app: A user logs a workout
Events already registered: account_created, workout_logged
```

| Line                        | What to do with it                                                                                                                                                                      |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Whisperr publishable key`  | The app key (`wpk_…`). Put it in a public environment variable ([step 3](#3-put-the-key-in-a-public-environment-variable)).                                                             |
| `Platform`                  | `Web`, `Next.js`, `React`, `Expo`, `React Native`, `Flutter` or `Swift`. Check it against the repository ([step 1](#1-detect-the-platform)).                                            |
| `Email permission`          | The user’s answer to **Does your signup get permission to email users?**: `Yes`, `Per user`, `No` or `Not answered`. It decides what `identify` sends ([step 5](#5-identify-the-user)). |
| `Main action in this app`   | The moment a user gets value from the app. Track it as the core event ([step 6](#6-track-events)).                                                                                      |
| `Events already registered` | Event codes that Whisperr already uses for this app. Track these codes exactly as written.                                                                                              |

**No key in the prompt?** Ask the user to create one. In the dashboard, on **Home → Connect your code → Copy prompt**, **Add an app key** puts a new key in the prompt (workspace owners and admins). Or the user creates one in **Settings → API keys** with the kind **App**. Every workspace can create keys, also before the trial starts.

## Use the Whisperr MCP server first

The server gives you this app’s own event plan, the exact SDK guide for the platform, and the events that really arrive. Then follow the playbook it returns. The rules in this guide still apply.

**Check for a connected server first.** Look for an MCP server named `whisperr`, or one with the address `https://mcp.whisperr.net/install` or `https://mcp.whisperr.net/mcp` (in Claude Code: `claude mcp list`). The dashboard’s coding-agent setup and the Whisperr plugin for Claude Code or Codex add one. When it is there, use it and do not add a second one, also when it says it needs authentication:

1. When it needs sign-in, start the sign-in yourself. In Claude Code, call the server’s `authenticate` tool and show the user the sign-in URL it returns. If there is no such tool, ask the user to run `/mcp`, select `whisperr` and choose **Authenticate**, and end your turn so they can. In other agents, ask the user to sign in where the agent lists its MCP servers. A sign-in that is still open is not a failure.
2. After you show the URL in Claude Code, do not end your turn to wait for a reply. When the user approves in the browser, Claude Code connects the server by itself, and with tool search (the default) the Whisperr tools appear in the same turn. Check for them about every 15 seconds for up to 5 minutes, and continue as soon as they are there. Between checks, only read the repository to find the platform and the app’s entry point; change no files yet. If the tools are still missing after 5 minutes, ask the user whether they finished signing in, and keep waiting while they are still signing in. In other agents, and after `/mcp`, wait until the user says they finished and the Whisperr tools work.
3. Use the install without MCP only when the user declines the sign-in or it cannot finish. Then tell the user that you install without MCP, and why.
4. Pass the prompt’s publishable key (`wpk_…`) as `publishable_key` to the install tools, starting with `get_required_events`. When the connection is signed in to another workspace than the key’s, the tools refuse with `workspace_mismatch` and say how to sign in again. In Claude Code: run `/mcp`, select `whisperr`, choose **Clear authentication**, then **Authenticate** and sign in to the account and app that the key belongs to. Never register events into another workspace.

The sign-in opens in the browser, where the user picks the Whisperr app and approves the scopes. Registering events needs a workspace owner or admin. Access tokens last 15 minutes, and the client renews them with the refresh token without a new sign-in. See [How long does the sign-in last?](/agents/#how-long-does-the-sign-in-last)

### Add the server

Skip this when a Whisperr server is already connected (see above).

The address for installs is `https://mcp.whisperr.net/install`. It has only the install tools. `https://mcp.whisperr.net/mcp` has every Whisperr tool, also data and dashboard questions. Both use Streamable HTTP and OAuth 2.1.

**Claude Code**

```bash
claude mcp add --transport http whisperr https://mcp.whisperr.net/install
```

Then run `/mcp`, select `whisperr` and choose **Authenticate**, and wait until the sign-in finishes.

**Cursor**

`mcp.json`

```json
{
  "mcpServers": {
    "whisperr": { "url": "https://mcp.whisperr.net/install" }
  }
}
```

Cursor opens the sign-in in the browser the first time it connects.

**Windsurf**

```bash
devin mcp add whisperr https://mcp.whisperr.net/install
```

Legacy Cascade agent: add this to `mcp_config.json` (`~/.config/devin/mcp_config.json`; older Windsurf installs use `~/.codeium/windsurf/mcp_config.json`):

`mcp_config.json`

```json
{
  "mcpServers": {
    "whisperr": { "serverUrl": "https://mcp.whisperr.net/install" }
  }
}
```

**Codex**

```bash
codex mcp add whisperr --url https://mcp.whisperr.net/install
codex mcp login whisperr
```

The login command opens the browser to sign in.

**Replit**

Replit accepts a custom remote MCP server. Add `https://mcp.whisperr.net/install`.

**Other clients**

Any MCP client that supports remote HTTP servers with OAuth:

```json
{
  "mcpServers": {
    "whisperr": { "type": "http", "url": "https://mcp.whisperr.net/install" }
  }
}
```

### Switch to another workspace

A connection is signed in to one Whisperr workspace. A client that already signed in for another app reuses that sign-in. When `get_workspace_context` names another app than the one you install, stop before you register anything, and ask the user to sign in again:

**Claude Code**

1. Run `/mcp` and select `whisperr`.
2. Choose **Clear authentication**.
3. Run `/mcp` again, select `whisperr`, and choose **Authenticate**.
4. In the browser, sign in to the Whisperr account of this app. On the consent screen, select this app under **Connect it to**, then choose **Allow access**.
5. Call `get_workspace_context` and check that it names this app.

Do not use **Re-authenticate** to switch workspaces. It can keep the previous workspace’s sign-in, even after you approve the new app.

**Cursor**

1. Open **Cursor Settings** → **Tools & MCP** and find `whisperr`.
2. Choose **Logout**, then sign in again from the same entry.
3. In the browser, sign in to the Whisperr account of this app, select this app under **Connect it to**, and choose **Allow access**.

When Cursor reconnects without opening the browser, remove the `whisperr` server, add it again, and sign in.

**Codex**

```bash
codex mcp logout whisperr
codex mcp login whisperr
```

The login command opens the browser. Sign in to the Whisperr account of this app, select this app under **Connect it to**, and choose **Allow access**.

**Other clients**

Sign out of the `whisperr` server where the client lists its MCP servers, connect it again, sign in to the Whisperr account of this app, and select this app under **Connect it to**.

### Call the tools

With the server connected, call the tools in this order:

1. `get_required_events`: the compact work order. It lists the events to wire with priority, slot and side, the install steps, and the playbook’s event guides (slot, title, priority, and a status once Whisperr’s setup has a plan).
2. Map the whole app: every screen or route, server handler, background job and webhook. Read the router, the data models and the server handlers. Note the core loop and where money changes hands. This map is the event inventory.
3. `get_event_guide`, once, with `slots` (the ones you will wire, the guides you cannot answer from the title, and the nice guides that fit this app) and `codes` (work-order events whose description or properties you need). It returns when and where each event fires and the property names Whisperr reads.
4. Wire every work-order event and guide moment that the app really has, without asking. Answer every must and should guide: present, with the file where it happens, or absent, with a one-line reason. A guide with status `found` is already covered. Never wire one with status `from_billing_source`. See [Track events](#6-track-events) for the base events, money moments and extra events.
5. `get_integration_playbook` with the platform (`web`, `react`, `nextjs`, `react_native`, `flutter`, `swift`, `node`, `python`, `php` or `dotnet`): the package, install command, key placement, init and calls.
6. Edit the code. Then register every new event code you wired in one draft: `prepare_code_source`, `create_registration_draft`, `begin_registration_validation`, then `submit_isolated_test_events` with one test event per event: one call for up to 50 events, otherwise batches of 50. Each test sends the property names that the code’s call site sends, with made-up values. Never add a property only to pass. `missing_properties` on a result names registered properties that the test did not send. Then `commit_registration`: when its `declared_not_sent` lists a property that the code does not have, drop it from the schema in the next registration. Never invent a value for it. Put `install_coverage` in your summary. When you registered nothing, read `get_install_coverage` instead. On a signed-in connection, a later `commit_registration` adds events to the ones already committed.
7. `get_received_events`: check that real events arrive while the user uses the app. You do not run the app yourself ([step 8](#8-build-and-verify)).
8. `get_setup_status`: tell the user the setup score and the next step.

Whisperr tools never return API keys. Ask the user for the key as described above. More about the server and the agent kit: [Install with your coding agent](/agents/).

If your agent cannot add an MCP server, or the user declines the sign-in or it cannot finish, use the install below and tell the user so. It needs no MCP.

## Install without MCP

### 1. Detect the platform

Check the `Platform` line against the repository. The repository wins when they disagree. Check the rows in order and use the first that matches: an Expo app also has `react` and `react-dom`, and the web SDKs do nothing in a native app.

| The repository has                                                                                     | Platform     | Packages                                                                         |
| ------------------------------------------------------------------------------------------------------ | ------------ | -------------------------------------------------------------------------------- |
| `package.json` with `expo`                                                                             | Expo         | `@whisperr/react-native`, and `@whisperr/expo` for push                          |
| `package.json` with `react-native`, without `expo`                                                     | React Native | `@whisperr/react-native`                                                         |
| `package.json` with `next`, without `expo` or `react-native`                                           | Next.js      | `@whisperr/next`, `@whisperr/web`                                                |
| `package.json` with `react` and `react-dom`, without `next`, `expo` or `react-native`                  | React        | `@whisperr/react`, `@whisperr/web`                                               |
| Another front-end `package.json` (Vite, Vue, Svelte, Angular, plain), without `expo` or `react-native` | Web          | `@whisperr/web`                                                                  |
| `pubspec.yaml` with the Flutter SDK                                                                    | Flutter      | `whisperr`, and `whisperr_firebase_messaging` for push with `firebase_messaging` |
| `Package.swift`, `*.xcodeproj` or `*.xcworkspace`, without React Native or Flutter                     | Swift        | `Whisperr` (Swift package)                                                       |
| A backend in Node.js, Python, PHP or .NET                                                              | Server       | `@whisperr/node`, `whisperr` (PyPI), `whisperr/php`, `Whisperr` (NuGet)          |

Install a client SDK in the app. When the repository also has a backend that knows about payments or email consent, add the server SDK there. Use the same user id in both, so the events land on one timeline.

### 2. Install the SDK

Install the latest version with the project’s package manager (npm, pnpm, yarn or bun for JavaScript). The versions below are the latest at the time of this docs build. The same list as JSON is [`https://docs.whisperr.net/sdk-versions.json`](https://docs.whisperr.net/sdk-versions.json).

| Platform                                                      | Install                                                                                                                                                                     | Latest |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| Web                                                           | `npm i @whisperr/web`                                                                                                                                                       | 0.4.0  |
| React                                                         | `npm i @whisperr/react @whisperr/web`                                                                                                                                       | 0.4.0  |
| Next.js                                                       | `npm i @whisperr/next @whisperr/web`                                                                                                                                        | 0.4.0  |
| Expo                                                          | `npx expo install @whisperr/react-native @react-native-async-storage/async-storage`                                                                                         | 0.7.0  |
| Expo, when the app already has push with `expo-notifications` | Add `@whisperr/expo` to the same command, and `expo-constants` or `expo-application` when the app does not have them. See [`@whisperr/expo`](#whisperrexpo-in-an-expo-app). | 0.2.0  |
| React Native                                                  | `npm i @whisperr/react-native @react-native-async-storage/async-storage`, then `npx pod-install` before the next iOS build                                                  | 0.7.0  |
| Flutter                                                       | `flutter pub add whisperr`. With push through `firebase_messaging`, see [step 7](#7-push-notifications) first.                                                              | 0.8.0  |
| Swift                                                         | Swift package `https://github.com/WhisperrAI/whisperr-swift.git`, from `0.7.0`, product `Whisperr` on the app target                                                        | 0.7.0  |
| Node.js                                                       | `npm i @whisperr/node`                                                                                                                                                      | 0.2.0  |
| Python                                                        | `pip install whisperr`                                                                                                                                                      | 0.2.1  |
| PHP, Laravel                                                  | `composer require whisperr/php`                                                                                                                                             | 0.3.0  |
| .NET                                                          | `dotnet add package Whisperr`. ASP.NET Core: also `dotnet add package Whisperr.AspNetCore`                                                                                  | 1.1.0  |

#### Keep the lockfile

The install must only add packages. It must not re-resolve the app’s dependency tree.

- Use the package manager that owns the lockfile: `package-lock.json` is npm, `pnpm-lock.yaml` is pnpm, `yarn.lock` is Yarn, `bun.lock` or `bun.lockb` is Bun. `npm i` in the table above means `pnpm add`, `yarn add` or `bun add` in those projects. `npx expo install` picks the right one.
- Install only packages the app does not have yet. Do not list a package that is already in `package.json`.
- Use the install flags the project already uses. When its README, CI or `.npmrc` uses `legacy-peer-deps`, pass it too: `npx expo install @whisperr/react-native @react-native-async-storage/async-storage -- --legacy-peer-deps` (with npm, `npm i <packages> --legacy-peer-deps`).
- When the install stops with a peer-dependency error (npm `ERESOLVE`), read the `Could not resolve dependency` lines. When they name no `@whisperr/` package, the conflict is between packages the app already has. Install the Whisperr packages with `--legacy-peer-deps` and leave the conflict to the user. When they name a `@whisperr/` package, stop and tell the user the versions in the error.
- Never fix a conflict with `--force`, `npm dedupe`, `npm audit fix`, a plain `npm install`, or by deleting the lockfile or `node_modules`.
- Then read the lockfile diff. New entries for the packages you installed and their dependencies are expected. When a package that was already locked is gone or has another version, restore `package.json` and the lockfile with `git checkout`, and install again with the project’s flags. When that happens again, stop and tell the user.

#### `@whisperr/expo` in an Expo app

`@whisperr/react-native` alone sends identify calls and events in Expo. It has no native code, so it needs no config plugin, no prebuild and no new build, and it works in Expo Go.

Install `@whisperr/expo` only when the app already has push with `expo-notifications` ([step 7](#7-push-notifications)). Its config plugin changes native code, so add the plugin to the app config only when the user agrees to a new build.

`npx expo install` adds the config plugin of a package to `plugins` by itself when the app config is `app.json`. With `app.config.js` or `app.config.ts` it prints the entry to add instead. Until the user agrees to a new build, remove `"@whisperr/expo"` when it was added, and do not add it when the CLI asks you to. Keep the rest of the install. Tell the user that push works without the plugin when the app already sets up `expo-notifications`, and that the plugin needs a new build. `expo-constants` and `expo-application` are native modules too: when the app does not have them yet, a development build needs a rebuild before it can load them. Expo Go includes them.

#### Check the SDK version

After the install, check the lockfile. The user’s email choice (`emailOptedIn` in [step 5](#5-identify-the-user)) needs at least:

| Package                                              | Version |
| ---------------------------------------------------- | ------- |
| `@whisperr/web`, `@whisperr/react`, `@whisperr/next` | 0.4.0   |
| `@whisperr/react-native` (React Native and Expo)     | 0.6.0   |
| Flutter `whisperr`                                   | 0.7.0   |
| Swift `Whisperr`                                     | 0.6.0   |

Swift in a plain Xcode project: ask the user to add the package in Xcode (**File → Add Package Dependencies**, the URL above, product `Whisperr` on the app target). Do not edit `project.pbxproj` by hand. With Tuist or XcodeGen, add the package to that manifest and generate the project again.

### 3. Put the key in a public environment variable

The publishable key (`wpk_…`) is made for client code: anyone can read it from the app. Put it in the platform’s public variable. Add the variable name, without the value, to `.env.example` when the project has one. When the project has no environment variables at all, a constant in the module that initializes Whisperr also works.

| Platform                    | Variable                                                                                                                                    | Read it with                                                         |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Web or React with Vite      | `VITE_WHISPERR_KEY`                                                                                                                         | `import.meta.env.VITE_WHISPERR_KEY`                                  |
| React with Create React App | `REACT_APP_WHISPERR_KEY`                                                                                                                    | `process.env.REACT_APP_WHISPERR_KEY`                                 |
| Next.js                     | `NEXT_PUBLIC_WHISPERR_KEY`                                                                                                                  | `process.env.NEXT_PUBLIC_WHISPERR_KEY`                               |
| Expo                        | `EXPO_PUBLIC_WHISPERR_KEY`                                                                                                                  | `process.env.EXPO_PUBLIC_WHISPERR_KEY`                               |
| React Native                | `WHISPERR_KEY` with the project’s config package, such as `react-native-config`. Without one, a constant in the init module.                | `Config.WHISPERR_KEY`                                                |
| Flutter                     | `--dart-define=WHISPERR_KEY=wpk_…`                                                                                                          | `const String.fromEnvironment('WHISPERR_KEY')`                       |
| Swift                       | `WHISPERR_PUBLISHABLE_KEY` in the app’s `.xcconfig`, mapped to the Info.plist key `WhisperrPublishableKey` as `$(WHISPERR_PUBLISHABLE_KEY)` | `Bundle.main.object(forInfoDictionaryKey: "WhisperrPublishableKey")` |

- Next.js builds the value into the bundle. Tell the user to set it in the hosting provider and build again.
- EAS builds need the variable in the EAS environment too.
- Flutter: add the define to the run configurations and CI.

The server key (`wrk_…`) is different. It never goes in client code or in a public variable. A server SDK reads it from `WHISPERR_API_KEY` in the server’s secret store. Ask the user to set it there. Do not ask them to paste it into the chat.

### 4. Initialize once at app start

**Web**

`src/lib/whisperr.ts`

```ts
import { Whisperr } from "@whisperr/web";


const key = import.meta.env.VITE_WHISPERR_KEY;
// Without a key (a preview or CI build), the SDK stays off.
export const whisperr = Whisperr.init({ apiKey: key ?? "", disabled: !key });
```

Import this module once from the app entry. `Whisperr.init()` returns the same client on every call and does nothing during server-side rendering.

**React**

`src/main.tsx`

```tsx
import { WhisperrProvider } from "@whisperr/react";


const key = import.meta.env.VITE_WHISPERR_KEY;


root.render(
  // Without a key (a preview or CI build), the SDK stays off instead of throwing.
  <WhisperrProvider options={{ apiKey: key ?? "", disabled: !key }}>
    <App />
  </WhisperrProvider>,
);
```

In components: `const whisperr = useWhisperr();` from `@whisperr/react`.

**Next.js**

`app/layout.tsx`

```tsx
import { WhisperrProvider } from "@whisperr/next";


const key = process.env.NEXT_PUBLIC_WHISPERR_KEY;


export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* Without a key (a preview or CI build), the SDK stays off instead of throwing. */}
        <WhisperrProvider options={{ apiKey: key ?? "", disabled: !key }}>{children}</WhisperrProvider>
      </body>
    </html>
  );
}
```

Pages Router: the same wrapper in `pages/_app.tsx`. Components that track start with `"use client"` and call `useWhisperr()` from `@whisperr/next`.

**Expo**

`src/lib/whisperr.ts`

```ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Whisperr } from "@whisperr/react-native";


const key = process.env.EXPO_PUBLIC_WHISPERR_KEY;


// Without a key, the SDK stays off.
export const whisperr = Whisperr.init({ apiKey: key ?? "", disabled: !key, storage: AsyncStorage });
```

Import it from the app entry: `app/_layout.tsx` with Expo Router, else `App.tsx`. The SDK has no native code, so it works in Expo Go. Do not run `expo prebuild` for it.

**React Native**

`src/lib/whisperr.ts`

```ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Whisperr } from "@whisperr/react-native";


// The publishable key is safe in app code. With react-native-config, use Config.WHISPERR_KEY.
const key = "wpk_…";


export const whisperr = Whisperr.init({ apiKey: key, storage: AsyncStorage });
```

Import it from `App.tsx`. Bare React Native has no built-in environment variables. When the project already uses a config package, such as `react-native-config`, read the key from it. Otherwise keep the constant: do not add a package only for the key.

**Flutter**

`lib/main.dart`

```dart
import 'package:flutter/widgets.dart';
import 'package:whisperr/whisperr.dart';


Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  const key = String.fromEnvironment('WHISPERR_KEY');
  if (key.isNotEmpty) await Whisperr.initialize(apiKey: key);
  runApp(const MyApp());
}
```

`Whisperr.initialize` throws for an empty key, and `Whisperr.instance` throws before it. Guard calls with `Whisperr.isInitialized`.

**Swift**

```swift
import Whisperr


// Once at launch: SwiftUI App.init or application(_:didFinishLaunchingWithOptions:).
let key = Bundle.main.object(forInfoDictionaryKey: "WhisperrPublishableKey") as? String ?? ""
Task { await Whisperr.initialize(apiKey: key) }
```

Later calls use `await Whisperr.shared`, which is `nil` before `initialize`. Trait and property values take `.string()`, `.number()` or `.bool()` for variables.

### 5. Identify the user

Call `identify` at three moments:

- right after sign-up succeeds,
- right after login succeeds,
- on every app start when a user is already signed in. This also covers users who signed in before the install.

On logout, track `user_logged_out`, then call `reset()`.

**Web**

```ts
whisperr.identify(user.id, {
  traits: { first_name: user.firstName },
  email: user.email,
  emailOptedIn: user.agreedToEmail, // see the table below; leave it out when the app does not know
});


// Logout:
whisperr.track("user_logged_out");
whisperr.reset();
```

React and Next.js: the same calls on the client from `useWhisperr()`, when the auth state resolves.

**React**

```ts
const whisperr = useWhisperr();
whisperr.identify(user.id, {
  traits: { first_name: user.firstName },
  email: user.email,
  emailOptedIn: user.agreedToEmail,
});
```

On logout: `whisperr.track("user_logged_out")`, then `whisperr.reset()`.

**Next.js**

```ts
"use client";
const whisperr = useWhisperr();
whisperr.identify(user.id, {
  traits: { first_name: user.firstName },
  email: user.email,
  emailOptedIn: user.agreedToEmail,
});
```

On logout: `whisperr.track("user_logged_out")`, then `whisperr.reset()`.

**Expo**

```ts
whisperr.identify(user.id, {
  traits: { first_name: user.firstName },
  email: user.email,
  emailOptedIn: user.agreedToEmail,
});


// Logout:
whisperr.track("user_logged_out");
whisperr.reset();
```

**React Native**

```ts
whisperr.identify(user.id, {
  traits: { first_name: user.firstName },
  email: user.email,
  emailOptedIn: user.agreedToEmail,
});


// Logout:
whisperr.track("user_logged_out");
whisperr.reset();
```

**Flutter**

```dart
if (Whisperr.isInitialized) {
  await Whisperr.instance.identify(
    user.id,
    traits: {'first_name': user.firstName},
    email: user.email,
    emailOptedIn: user.agreedToEmail,
  );
}


// Logout:
if (Whisperr.isInitialized) {
  Whisperr.instance.track('user_logged_out');
  await Whisperr.instance.reset();
}
```

An app that uses `whisperr_firebase_messaging` stays on an older `whisperr` for now, where this snippet does not compile. See the Flutter tab of [step 7](#7-push-notifications).

**Swift**

```swift
try await Whisperr.shared?.identify(
    user.id,
    traits: ["first_name": .string(user.firstName)],
    email: user.email,
    emailOptedIn: user.agreedToEmail
)


// Logout:
try await Whisperr.shared?.track("user_logged_out")
await Whisperr.shared?.reset()
```

Rules for every platform:

- Use the app’s stable user id, never the email address.
- `first_name` is the only name trait. Never send a last name, full name, email, phone, address or birth date as a trait or an event property.
- Send the email only as `email`. Send no phone number.
- Leave out a trait or the email when the app does not have it.
- When the app has its own language setting, call `setAppLanguage("<BCP 47 tag>")` with the language the user picked. It is not the device language.

#### Email permission

`emailOptedIn` is the user’s own email choice. Never set it to `true` for a user who did not agree. Then follow the `Email permission` line:

| `Email permission`         | What the app sends                                                                                                                     | What Whisperr does                                                                                             |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `Yes`                      | `email`. Add `emailOptedIn: false` when the user declined or turned email off in the app. When the app asks the user, send the answer. | Opts the address in, unless `emailOptedIn` is `false`.                                                         |
| `Per user`                 | `email` with `emailOptedIn: true` for a user who agreed in the app (a signup checkbox or a setting), `false` for a user who declined.  | Opts the address in only with `emailOptedIn: true`.                                                            |
| `No`                       | `email`, with `emailOptedIn` when the app asks the user.                                                                               | Stores the address as pending. Only the backend can confirm it, with the server key.                           |
| `Not answered`, or no line | Decide from the app’s code: see below.                                                                                                 | Stores the address as pending until the user answers. Then Whisperr checks the address again under the answer. |

**No `Email permission` line, or `Not answered`: read the app.** Find how the app itself emails users today:

- **Email is on by default.** The app emails the signup address unless the user opts out, for example with an unsubscribe link or a Settings toggle that starts on. Then the signup gives permission to email. Send `email` as for `Yes`: `emailOptedIn: false` only for a user who turned email off, and no `emailOptedIn` otherwise. Report email as permitted, with one step for the user: answer **Yes** to **Does your signup get permission to email users?**. Until they answer, the addresses wait as pending. When they answer, Whisperr checks them again and opts them in.
- **Email needs the user’s opt-in.** The app emails only users who agreed, for example with a checkbox or a Settings toggle that starts off. Send `email` as for `Per user`, and tell the user to answer **Per user**.
- **The app sends no email to users.** Send `email` as for `No`, and tell the user to answer **No**. Whisperr then does not email these users unless their backend confirms consent with the server key.

Then:

- **`Per user`, and the app never asks users about email:** every address stays pending. Tell the user. They can add an email choice to the app, answer `Yes` if every signup agrees to email, or confirm consent from a backend.
- **`No`, and the repository has a backend:** confirm consent there with the server key for users who agreed to email. See [Server SDKs](#server-sdks).
- **`No`, and there is no backend:** tell the user that Whisperr cannot email these users until they change the answer, or until a backend confirms consent.
- **`Not answered`, or no line:** tell the user which answer fits the app and where to give it: **Home → Connect your code** or **Settings → API keys**.

Whisperr also keeps every earlier opt-out, including the unsubscribe link, and it limits what the app key can do with email. For example, the app cannot change a user’s existing address, and one address belongs to one user. See [Contact consent](/concepts/consent/).

### 6. Track events

Before you pick events, map the whole app, unless you already did it in the MCP steps: every screen or route, server handler, background job and webhook. Read the router, the data models and the server handlers. Note the core loop (the action users come back for) and where money changes hands. This map is the event inventory.

Wire every base event that the app really has, without asking. When the app does not have one, skip it and give the reason in your summary.

| Event                                                       | When to track it                                                                                                                                 |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `account_created`                                           | Once per user, right after a new account is created.                                                                                             |
| The app’s core action                                       | Each time the user does what the app is for.                                                                                                     |
| The core action’s attempt                                   | When the user starts the core action, if starting and finishing are separate moments. When the app has it.                                       |
| `user_logged_out`                                           | On logout, before `reset()`.                                                                                                                     |
| `account_deleted`                                           | Once the account deletion succeeds, from the server when the repository has one. See **Account deletion and trials** below. When the app has it. |
| `onboarding_started`, `onboarding_completed`                | When setup or onboarding starts and finishes. When the app has it.                                                                               |
| `goal_set`, `goal_completed`                                | When the user sets a goal, and when they reach it. When the app has it.                                                                          |
| `streak_updated`, `streak_broken`, `streak_protection_used` | When a streak grows, ends, or a freeze or repair saves it. When the app has it.                                                                  |
| `milestone_reached`                                         | When the user reaches a milestone the app shows them. When the app has it.                                                                       |
| `item_saved`                                                | When the user saves or favorites the app’s main object. When the app has it.                                                                     |
| `reminder_set`, `reminder_disabled`                         | When the user turns a reminder on or off. When the app has it.                                                                                   |
| `feature_used`, `premium_feature_used`                      | When the user uses a key feature, or a paid one. When the app has it.                                                                            |
| `limit_hit`                                                 | When the user reaches a free-plan or usage limit. When the app has it.                                                                           |
| `marketing_consent_changed`                                 | When the user changes their email or notification choice in the app. When the app has it.                                                        |

Name each event after the app’s own moment when a clearer name fits, for example `lesson_started` for the attempt or `habit_created` for a goal. With the MCP server, register each one with the slot of the guide it answers, or slot `none` when no guide fits.

Never fake an event. Track only what the production code really does, never from dev, test or simulation routes.

**A new account.** Track `account_created` only on the code path that runs for a new user: the success of a sign-up form, or a sign-in where the auth provider or the app’s server says that the user is new, for example a new-user flag in the response or the server creating the user record. Sign in with Apple, Google sign-in and magic links usually return the same callback for new and returning users, so never track it on every sign-in. When the app cannot tell a new user from a returning one, do not track `account_created` from the client. Tell the user, and track it from the backend where the user record is created ([Server SDKs](#server-sdks)), when the repository has one.

**The core action.** Use the `Main action in this app` line when the prompt has it. Otherwise find it in the app’s code. Do not guess from the app’s name:

- Look at the main screen or tab and its primary button: what does it create, log, complete or buy?
- Follow that action to the write that saves it: the API call, the database insert or the local store. Track at that point.
- Check what the app limits, counts or rewards: free-plan limits, streaks, progress and the stats it shows the user. These usually count the core action.
- When the app already tracks events with another tool, reuse the moment of its main event, with a Whisperr code.

Pick one action. Name the event after it, object then verb in the past tense. In your summary, name the event, the file where it fires, and why you picked it, so the user can change it. These are examples from different kinds of apps. Do not copy one: pick the name that fits this app.

| Kind of app       | Example core event |
| ----------------- | ------------------ |
| Fitness           | `workout_logged`   |
| Notes             | `note_created`     |
| Language learning | `lesson_completed` |
| Shop              | `order_placed`     |
| Game              | `level_completed`  |
| Budgeting         | `expense_added`    |

**Events already registered.** When the prompt lists them, track those codes exactly as written, at the moment their name describes. Do not rename them.

**Money.** Track money events only when the app really has the feature:

| The app has                                                 | Events                                                                                           |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| A paywall                                                   | `paywall_viewed`, `paywall_dismissed`                                                            |
| A screen to manage the subscription                         | `manage_subscription_viewed`                                                                     |
| A free trial                                                | `trial_started`, `trial_converted`                                                               |
| A subscription                                              | `subscription_started`, `subscription_renewed`, `subscription_cancelled`, `subscription_expired` |
| One-off purchases, such as credit packs, tips or coin packs | One event per kind of purchase, for example `credit_pack_purchased`                              |

- Track the paywall and subscription screen events from the app. `paywall_viewed` has a `placement` property that names where the paywall opened. With the MCP server, register both paywall events with slot `none`.
- Trial and subscription events come from the backend or the billing tool, never from client code. `trial_started` has `trial_ends_at`. When no billing tool sends it, track `subscription_payment_failed` only from a real failed-payment or billing-retry webhook, never from a dev or simulation route.
- Send one-off purchases from the backend that records the purchase. Send them from the app only when it has no backend, and only after the store confirms the purchase.

When RevenueCat, Superwall or Adapty handles purchases, connect that tool in the dashboard. It sends the trial and subscription events, so do not track them yourself. The app still tracks the moments that the tool does not see: `paywall_viewed`, `paywall_dismissed` and `manage_subscription_viewed`.

**Other events.** Also add every other real moment that helps keep users and fits none of the events above, such as an abandoned flow or the main object archived. Do not ask first. With the MCP server, map each moment to a guide slot when one fits, and register the rest with slot `none`. Ask the user only about a moment you cannot tell happens in production, or one that would carry sensitive data. Name every event in your summary, with its file and line.

There is no cap and no target. Past about 60 events, check for duplicates, heartbeats and UI noise such as renders, scrolls and taps. That is a sanity check, not a cap.

**Properties.** Send every property the code really has at that point, each from a real variable: ids, plan, amounts, counts, reasons. Never send a property with a made-up, default or placeholder value. Use these names when the app has the value: `lifetime_count` (how many times this user has done it), `is_first_ever`, `streak_length` and, for a trial, `trial_ends_at`.

**Account deletion and trials.** When users can delete their account, track `account_deleted` once the deletion succeeds, from the server when the repository has one ([Server SDKs](#server-sdks)). Then call `reset()` in the app and do not identify that user again. Whisperr stops scoring and messaging the user. When the app offers a free trial, track `trial_started` with `trial_ends_at` from the backend when the trial starts, unless the app uses RevenueCat, Superwall or Adapty (then connect that tool instead). A trial start is not `subscription_started`.

**Web**

```ts
whisperr.track("<event_code>", { plain_property: "value" });
```

**React**

```ts
whisperr.track("<event_code>", { plain_property: "value" });
```

**Next.js**

```ts
whisperr.track("<event_code>", { plain_property: "value" });
```

**Expo**

```ts
whisperr.track("<event_code>", { plain_property: "value" });
```

**React Native**

```ts
whisperr.track("<event_code>", { plain_property: "value" });
```

**Flutter**

```dart
if (Whisperr.isInitialized) {
  Whisperr.instance.track('<event_code>', properties: {'plain_property': 'value'});
}
```

**Swift**

```swift
try await Whisperr.shared?.track("<event_code>", properties: ["plain_property": "value"])
```

Rules for events:

- Track where the action **succeeds**: after the save, the upload or the API call returns. Not on a button tap, and never during render or `build()`.
- No heartbeats, timers or polling events.
- Codes are lowercase `snake_case`, start with a letter and have at most 64 characters, for example `note_created`.
- Do not send the SDK’s automatic events yourself: `app_installed`, `app_updated`, `app_opened`, `app_backgrounded`, `screen_viewed`, `page_viewed`, `push_opened`, `push_received` and `push_permission_changed`.
- Properties are plain values. No personal data and no free text that users typed.
- **No subscription, renewal or trial events from client code.** They come from the billing tool or the backend (one-off purchases: see **Money** above). With RevenueCat, call `Purchases.logIn(user.id)` with the same id as `identify`, and `Purchases.logOut()` on logout. With Superwall or Adapty, call that SDK’s identify with the same id, and its reset on logout. Tell the user which billing system the app uses. See [RevenueCat](/integrations/revenuecat/), [Superwall](/integrations/superwall/) and [Adapty](/integrations/adapty/).

### 7. Push notifications

Do this only when the app already has push notifications. Do not add push to an app that has none: tell the user that push needs a push provider and, on mobile, a new native build, and let them choose where the permission prompt appears.

Whisperr sends push through the customer’s own provider. The user connects it in the dashboard under **Integrations → Push**. See [Connect a push provider](/integrations/push-providers/).

**Expo**

Install `@whisperr/expo` ([step 2](#2-install-the-sdk)). After `identify`, and again right after the app’s own permission request:

```ts
import { registerForPushNotifications } from "@whisperr/expo";


await registerForPushNotifications({ requestPermission: false });
```

It sends the token and the permission to Whisperr, keeps both current, and never shows a prompt of its own. Pass `tokenType: "device"` when the app sends through its own APNs key or Firebase project. In the root layout, call `useWhisperrNotificationResponse()` and route to its `deepLink`. Add `"@whisperr/expo"` to `plugins` in the app config only when the user agrees to a new build (`eas build`): the plugin changes native code. See [Expo SDK](/sdks/expo/#register-for-push).

**React Native**

After `identify`, on every app start and on each token refresh while a user is signed in:

```ts
whisperr.setPushToken({ token, kind: "fcm" }); // or the token object from expo-notifications
whisperr.setPushPermission("granted"); // the OS status, on launch and on each return to the foreground
```

When the user taps a notification, call `whisperr.trackPushOpened(response)` and route to the `deepLink` it returns. See [React Native SDK](/sdks/react-native/#push-notifications).

**Flutter**

With `firebase_messaging`, use `whisperr_firebase_messaging`.

`whisperr_firebase_messaging` 0.1.0 works only with `whisperr` below 0.7.0. Until a version for `whisperr` 0.8.0 is on pub.dev, an app that uses it stays on that older `whisperr`: `flutter pub add "whisperr:^0.6.0" whisperr_firebase_messaging`.

That older `whisperr` changes [step 5](#5-identify-the-user):

- It has no `emailOptedIn`, so the step 5 Flutter snippet does not compile.

- Its `email:` shortcut always sends `opted_in: true`. Do not use it: with `Email permission: Yes` it would opt in users who declined.

- Send the address as a channel. Add `optedIn: false` only for a user who declined:

  ```dart
  if (Whisperr.isInitialized) {
    await Whisperr.instance.identify(
      user.id,
      traits: {'first_name': user.firstName},
      channels: [
        WhisperrChannel.email(user.email, optedIn: user.agreedToEmail == false ? false : null),
      ],
    );
  }
  ```

- With `Yes`, Whisperr then opts the address in unless the user declined. With `Per user`, the app cannot opt anyone in: confirm consent from the backend with the server key ([Server SDKs](#server-sdks)), and tell the user.

After `initialize`, where the app already asks for permission:

```dart
if (Whisperr.isInitialized) {
  await Whisperr.instance.registerFirebaseMessaging(
    FirebaseMessaging.instance,
    requestPermission: false,
  );


  await Whisperr.instance.handleNotificationOpens(
    FirebaseMessaging.instance,
    onOpen: (open, message) {
      // Route to open.deepLink when it is set.
    },
  );
}
```

See [Flutter SDK](/sdks/flutter/#push-notifications).

**Swift**

```swift
// application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
Whisperr.setPushToken(deviceToken)


// With Firebase Cloud Messaging instead:
Whisperr.setPushToken(fcmToken: token, firebaseProjectID: FirebaseApp.app()?.options.projectID)


// userNotificationCenter(_:didReceive:withCompletionHandler:)
if let url = Whisperr.handleNotificationResponse(response) { open(url) }
```

See [Swift SDK](/sdks/swift/#push-notifications).

### 8. Build and verify

Do not start or use emulators, simulators or physical devices, do not install builds on them, do not run the app’s UI, and do not put a proxy in front of `api.whisperr.net`, unless the user asks you to. The user runs the app.

1. Run the project’s own type check or lint. The change must add no compiler, type or analyzer errors. Run a build only when it does not start a simulator, install on a device or start the app.
2. Tell the user which flows to use so that each event fires once. Start with these: sign up with a new account, do the core action, log out. Then name the flows for the other events you wired. A sign-in with an existing account must not send `account_created`.
3. With the MCP server, call `get_received_events` while they do it. Without it, ask the user to watch **See events from your app** on the dashboard **Home**. When nothing arrives, check the key in the project: it is the `wpk_…` key from the prompt, not empty and not a server key (`wrk_…`).

What the user sees: on **Home → Connect your code**, and on the **Data** page, the events that arrived, within about a minute.

Whisperr registers the event codes that it receives from the app on its own, until an event list is saved for the app. So the user can build the plan from the events the app really sends. Before they build it, they can check the list in the dashboard, pick the core event, and remove events they do not want. See [What to track](/concepts/events/#what-to-track).

### 9. Tell the user what you did

End with a short summary:

- whether you used the Whisperr MCP server, and if not, why,
- the SDK and version you installed, and the file where it starts,
- where the key lives, and what the user still sets in hosting, EAS or CI,
- where each event fires (file and line), the core event you picked and why,
- each base event and money event you checked and whether the app has it, with a one-line reason from the code when it does not; with the MCP server, the same for every must and should guide,
- the extra events you added, and any you asked the user about,
- the file and line of the variable behind each property you send,
- with the MCP server: the `install_coverage` from `commit_registration` (or `get_install_coverage`), and any `declared_not_sent` properties,
- what happens to email under their `Email permission` answer, or the answer that fits the app when the prompt had none, and what they should do about it,
- any change the install made to `app.json` or the lockfile, and why,
- the billing system the app uses,
- push: wired, or what it would need,
- the flows to use to check that events arrive.

## Server SDKs

A backend uses the server key (`wrk_…`) and passes the user id on every call. Use the same id as the app’s `identify`.

`server.ts`

```ts
import { createWhisperr } from "@whisperr/node";


const whisperr = createWhisperr({ apiKey: process.env.WHISPERR_API_KEY! });


// Only after your records show that the user agreed to email:
whisperr.identify(user.id, {
  channels: [{ type: "email", address: user.email, optedIn: true }],
});


// A fact only the server knows. The name is an example:
whisperr.track(user.id, "payment_failed", { amount_cents: 4900 });
```

A server-key identify opts an email address in, whatever the `Email permission` answer. The server SDKs send `opted_in: true` when you leave `optedIn` out, so send an email channel from the server only for a user who agreed. The same calls exist in [Python](/sdks/python/), [PHP](/sdks/php/) and [.NET](/sdks/dotnet/). See [Confirm consent with identify](/concepts/consent/#confirm-consent-with-identify).

## When something does not fit

| Situation                                          | What to do                                                                                                                                                        |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No app key                                         | Ask the user to create one: **Home → Connect your code → Copy prompt → Add an app key** (owners and admins), or **Settings → API keys** with the kind **App**.    |
| The app has no sign-in                             | Events still go out under an anonymous id. Whisperr never messages a user it knows only by an anonymous id, so tell the user that messages need a signed-in user. |
| No Whisperr SDK for the platform                   | Send events with the [ingestion API](/api/overview/), or tell the user.                                                                                           |
| The app already tracks with another analytics tool | Add Whisperr calls next to the existing ones at the same moments. Do not remove the other tool.                                                                   |

Source: https://docs.whisperr.net/setup/
