Setup guide for coding agents
Last updated
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
Section titled “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:
Install Whisperr in this app: follow https://docs.whisperr.net/setup.mdWhisperr publishable key: wpk_…Platform: ExpoEmail permission: Per userMain action in this app: A user logs a workoutEvents 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). |
Platform | Web, Next.js, React, Expo, React Native, Flutter or Swift. Check it against the repository (step 1). |
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). |
Main action in this app | The moment a user gets value from the app. Track it as the core event (step 6). |
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
Section titled “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:
- When it needs sign-in, start the sign-in yourself. In Claude Code, call
the server’s
authenticatetool and show the user the sign-in URL it returns. If there is no such tool, ask the user to run/mcp, selectwhisperrand 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. - 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. - 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.
- Pass the prompt’s publishable key (
wpk_…) aspublishable_keyto the install tools, starting withget_required_events. When the connection is signed in to another workspace than the key’s, the tools refuse withworkspace_mismatchand say how to sign in again. In Claude Code: run/mcp, selectwhisperr, 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?
Add the server
Section titled “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 mcp add --transport http whisperr https://mcp.whisperr.net/installThen run /mcp, select whisperr and choose Authenticate, and wait
until the sign-in finishes.
{ "mcpServers": { "whisperr": { "url": "https://mcp.whisperr.net/install" } }}Cursor opens the sign-in in the browser the first time it connects.
devin mcp add whisperr https://mcp.whisperr.net/installLegacy Cascade agent: add this to mcp_config.json
(~/.config/devin/mcp_config.json; older Windsurf installs use
~/.codeium/windsurf/mcp_config.json):
{ "mcpServers": { "whisperr": { "serverUrl": "https://mcp.whisperr.net/install" } }}codex mcp add whisperr --url https://mcp.whisperr.net/installcodex mcp login whisperrThe login command opens the browser to sign in.
Replit accepts a custom remote MCP server. Add
https://mcp.whisperr.net/install.
Any MCP client that supports remote HTTP servers with OAuth:
{ "mcpServers": { "whisperr": { "type": "http", "url": "https://mcp.whisperr.net/install" } }}Switch to another workspace
Section titled “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:
- Run
/mcpand selectwhisperr. - Choose Clear authentication.
- Run
/mcpagain, selectwhisperr, and choose Authenticate. - 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.
- Call
get_workspace_contextand 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.
- Open Cursor Settings → Tools & MCP and find
whisperr. - Choose Logout, then sign in again from the same entry.
- 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 mcp logout whisperrcodex mcp login whisperrThe 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.
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
Section titled “Call the tools”With the server connected, call the tools in this order:
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).- 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.
get_event_guide, once, withslots(the ones you will wire, the guides you cannot answer from the title, and the nice guides that fit this app) andcodes(work-order events whose description or properties you need). It returns when and where each event fires and the property names Whisperr reads.- 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
foundis already covered. Never wire one with statusfrom_billing_source. See Track events for the base events, money moments and extra events. get_integration_playbookwith the platform (web,react,nextjs,react_native,flutter,swift,node,python,phpordotnet): the package, install command, key placement, init and calls.- Edit the code. Then register every new event code you wired in one
draft:
prepare_code_source,create_registration_draft,begin_registration_validation, thensubmit_isolated_test_eventswith 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_propertieson a result names registered properties that the test did not send. Thencommit_registration: when itsdeclared_not_sentlists a property that the code does not have, drop it from the schema in the next registration. Never invent a value for it. Putinstall_coveragein your summary. When you registered nothing, readget_install_coverageinstead. On a signed-in connection, a latercommit_registrationadds events to the ones already committed. get_received_events: check that real events arrive while the user uses the app. You do not run the app yourself (step 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.
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
Section titled “Install without MCP”1. Detect the platform
Section titled “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
Section titled “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.
| 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. | 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 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
Section titled “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.jsonis npm,pnpm-lock.yamlis pnpm,yarn.lockis Yarn,bun.lockorbun.lockbis Bun.npm iin the table above meanspnpm add,yarn addorbun addin those projects.npx expo installpicks 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
.npmrcuseslegacy-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 theCould not resolve dependencylines. When they name no@whisperr/package, the conflict is between packages the app already has. Install the Whisperr packages with--legacy-peer-depsand 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 plainnpm install, or by deleting the lockfile ornode_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.jsonand the lockfile withgit checkout, and install again with the project’s flags. When that happens again, stop and tell the user.
@whisperr/expo in an Expo app
Section titled “@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). 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
Section titled “Check the SDK version”After the install, check the lockfile. The user’s email choice
(emailOptedIn in step 5) 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
Section titled “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
Section titled “4. Initialize once at app start”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.
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.
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.
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.
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.
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.
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
Section titled “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().
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.
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().
"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().
whisperr.identify(user.id, { traits: { first_name: user.firstName }, email: user.email, emailOptedIn: user.agreedToEmail,});
// Logout:whisperr.track("user_logged_out");whisperr.reset();whisperr.identify(user.id, { traits: { first_name: user.firstName }, email: user.email, emailOptedIn: user.agreedToEmail,});
// Logout:whisperr.track("user_logged_out");whisperr.reset();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.
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_nameis 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
Section titled “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
emailas forYes:emailOptedIn: falseonly for a user who turned email off, and noemailOptedInotherwise. 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
emailas forPer user, and tell the user to answer Per user. - The app sends no email to users. Send
emailas forNo, 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, answerYesif 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.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.
6. Track events
Section titled “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), 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_viewedhas aplacementproperty that names where the paywall opened. With the MCP server, register both paywall events with slotnone. - Trial and subscription events come from the backend or the billing tool,
never from client code.
trial_startedhastrial_ends_at. When no billing tool sends it, tracksubscription_payment_failedonly 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). 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.
whisperr.track("<event_code>", { plain_property: "value" });whisperr.track("<event_code>", { plain_property: "value" });whisperr.track("<event_code>", { plain_property: "value" });whisperr.track("<event_code>", { plain_property: "value" });whisperr.track("<event_code>", { plain_property: "value" });if (Whisperr.isInitialized) { Whisperr.instance.track('<event_code>', properties: {'plain_property': 'value'});}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 examplenote_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_receivedandpush_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 asidentify, andPurchases.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, Superwall and Adapty.
7. Push notifications
Section titled “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.
Install @whisperr/expo (step 2). After identify,
and again right after the app’s own permission request:
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.
After identify, on every app start and on each token refresh while a user
is signed in:
whisperr.setPushToken({ token, kind: "fcm" }); // or the token object from expo-notificationswhisperr.setPushPermission("granted"); // the OS status, on launch and on each return to the foregroundWhen the user taps a notification, call whisperr.trackPushOpened(response)
and route to the deepLink it returns. See
React Native SDK.
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:
-
It has no
emailOptedIn, so the step 5 Flutter snippet does not compile. -
Its
email:shortcut always sendsopted_in: true. Do not use it: withEmail permission: Yesit would opt in users who declined. -
Send the address as a channel. Add
optedIn: falseonly for a user who declined: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. WithPer user, the app cannot opt anyone in: confirm consent from the backend with the server key (Server SDKs), and tell the user.
After initialize, where the app already asks for permission:
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.
// 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.
8. Build and verify
Section titled “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.
- 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.
- 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. - With the MCP server, call
get_received_eventswhile 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 thewpk_…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.
9. Tell the user what you did
Section titled “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_coveragefromcommit_registration(orget_install_coverage), and anydeclared_not_sentproperties, - what happens to email under their
Email permissionanswer, 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.jsonor 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
Section titled “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.
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, PHP
and .NET. See
Confirm consent with identify.
When something does not fit
Section titled “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, 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. |