React Native
Use the FounderHQ React Native SDK.
Capture screens, app lifecycle, and your own events from a React Native or Expo app.
Before you start
You need a publishable key (fhq_pk_...) for the brand you want to
measure. See Getting started.
Install
npm install @founderhq/events-react-native @react-native-async-storage/async-storageThe SDK needs React Native 0.73 or later and AsyncStorage 1.21 or later.
For the Expo build, add the three optional packages it reads context from:
npx expo install expo-application expo-device expo-localizationInitialize
Create one client and share it across the app.
import { FounderHqReactNativeClient } from "@founderhq/events-react-native";
export const events = new FounderHqReactNativeClient("fhq_pk_XXXX");On Expo, use the Expo client instead. It adds app, device, and locale context for you.
import { createFounderHqExpoClient } from "@founderhq/events-react-native/expo";
export const events = createFounderHqExpoClient("fhq_pk_XXXX");The client loads stored state, applies your capture settings, starts app lifecycle capture, and records the session start on its own.
Capture events
events.capture("trial.started", { plan: "growth" });track is an alias for capture. Both queue the work and return at once.
Every writing method has an ...AndWait twin that resolves after the work
is applied: captureAndWait, identifyAndWait, screenAndWait,
setAccountAndWait, clearAccountAndWait, resetAccountsAndWait,
setAccountPropertiesAndWait, setPersonPropertiesAndWait, and
resetAndWait. Use them in tests, and before the app leaves a flow.
readyForCapture() resolves once startup and the queued calls are done.
Record a deep link when your app opens from one:
events.captureDeepLink(url);captureDeepLink records the link without its query string, and keeps the
campaign parameters it recognizes.
Properties on every event
register adds properties to every later event. registerOnce does the
same but keeps a value that is already set. unregister removes one.
events.register({ plan: "pro" });
events.unregister("plan");Track screens
Record a screen yourself with events.screen("Pricing"), or wire up your
router.
React Navigation:
import { createReactNavigationTracker } from "@founderhq/events-react-native";
const tracker = createReactNavigationTracker(events, navigationRef);
<NavigationContainer
ref={navigationRef}
onReady={tracker.onReady}
onStateChange={tracker.onStateChange}
/>;Expo Router:
import { createExpoRouterTracker } from "@founderhq/events-react-native";
const trackPath = createExpoRouterTracker(events);
trackPath(pathname, params);Both trackers skip a repeat of the screen the app is already on.
Screen events include $screen_name, a unique $screen_id, and
$prefers_color_scheme: "dark" or "light" from
Appearance.getColorScheme() at capture time. The SDK omits the color
scheme when the system returns null or appearance is unavailable.
Adding properties to automatic events
Use autoProperties to add properties to $screen, $autocapture, and
$rageclick:
const events = new FounderHqReactNativeClient("fhq_pk_...", {
capture_element_interactions: true,
autoProperties: ({ screenName, elementDescription }) => ({
section: screenName === "Pricing" ? "sales" : "app",
...(elementDescription?.test_id
? { control: elementDescription.test_id }
: {}),
}),
});The exported FounderHqAutoPropertiesContext contains event and
screenName, which is null before the first screen. For taps, element
is the touched host view's memoizedProps object from the React fiber
(not a native view instance or necessarily the nearest control's props),
and elementDescription is the control
recorded in $elements[0]. These two fields are absent for screens. Raw
component props stay on the device unless your callback returns values from
them.
The callback runs for both navigation helpers, every screen() call, and
each tap or rage tap. Return an object, null, or undefined. If it throws,
the event still sends without callback properties, and the SDK warns once
per client. Remote config updates keep the callback. Lifecycle, session,
identity, push, purchase, and your own capture() calls do not call it,
even when you pass an automatic event name. This SDK does not record
$dead_click.
For taps, put fhqProps on the touched component or any ancestor:
<View fhqProps={{ product_id: "sku_1" }}>
<Pressable fhqProps={{ action: "buy" }} onPress={buy}>
<Text>Buy</Text>
</Pressable>
</View>A tap anywhere inside the button gets top-level product_id and action
properties. The declarative walk starts at the touched host view's React
fiber (_targetInst on the touch event), then follows every ancestor,
including those above the five entries recorded in $elements. The nearest
valid value wins. Keys stay as given; snake_case is the convention.
fhqNoCapture suppresses the whole subtree, including when set on a distant
ancestor. Control and entered-value detection inspect only the first 20
fibers and skip React context providers and consumers; real text inputs are
still excluded.
Both props are typed on View, Pressable, Text, TouchableOpacity,
TouchableWithoutFeedback, and TouchableHighlight (and components that
inherit their props). Custom components can declare
fhqProps?: Record<string, unknown> and fhqNoCapture?: boolean in their props.
Under React Native's opt-in Strict TypeScript API, this module augmentation
does not apply; use a cast or the autoProperties callback instead.
Later values win in this order: automatic properties → autoProperties →
fhqProps → explicit screen(name, properties) values.
Both new sources accept keys of 1–64 characters that do not start with $.
Values must be strings, finite numbers, or booleans. Strings are trimmed
and cut to 200 characters; other values are dropped. Each event keeps up
to 20 extra keys: nearest declarative props first, then ancestors, then
callback keys, in object-entry order. The first valid value wins; invalid
values do not use a slot. Explicit screen() and capture() properties
keep their existing behavior and are not subject to these limits.
beforeSend
Use beforeSend to change or drop a finished event:
const events = new FounderHqReactNativeClient("fhq_pk_...", {
beforeSend: (event) => {
if (event.event === "internal.debug") return null;
delete event.properties.email;
return event;
},
});It runs synchronously on the JS thread, after automatic, callback,
declarative, and explicit properties are merged, immediately before queueing.
Keep it fast. Return the event, possibly changed, or null or undefined to
drop it. A thrown error also drops the event. The event type is exported as
FounderHqV2Event.
Purchase control events ($mobile_purchase_prepared and
$mobile_purchase_claim) bypass beforeSend and are queued exactly as the
SDK built them, so the hook cannot break revenue attribution. Other events,
including lifecycle, session, custom, and identity events, pass through it.
An $identify synthesized after an identity-rotation directive also passes
through the hook. It may already contain changes from the first call, so
write idempotent hooks. Changing or dropping identity and session events can
affect identity linking and session reporting.
Hook results follow these rules:
- Only
uuid,event,distinct_id,timestamp,properties,session_id,window_id, andoptions.process_person_profileare retained. Other envelope and options keys, includingcookieless_mode, are stripped. eventis trimmed and must be nonempty. It may keep the original name, including an internal name. A different name must pass the public event-name check after trimming (at most 200 characters, no$prefix unless it is an app-sendable reserved name). Renaming to a purchase control name is rejected.uuidmust have RFC 4122 UUID syntax: 8-4-4-4-12 hex digits, version 1–8, variant 8, 9, a, or b, case-insensitively. This also applies to ids from a customuuidoption: with abeforeSend, an event whose id is not an RFC 4122 UUID is dropped.distinct_idis trimmed and must contain 1–400 characters and pass the SDK's illegal-distinct-ID check. The trimmed value is queued.timestampmust be a real calendar date-time in RFC 3339 form:YYYY-MM-DDTHH:MM:SS, optional fractional seconds, thenZor±HH:MM. Whole seconds are valid; impossible dates such as 30 February are rejected.propertiesmust serialize to a JSON object, andoptions.process_person_profilemust be a boolean.- Optional
session_idis kept only if it is a UUIDv7 with variant 8–b. Optionalwindow_idis kept only if it is a string of at most 200 characters. Invalid optional fields are removed without dropping the event.
An invalid required field or an unserializable retained value drops the event. Valid results are snapshotted so later mutations cannot change the queue. Without a hook, SDK-built events are queued untouched.
Identify a contact
events.identify("user_42", { email: "jane@acme.com" });Pass an account in the third argument to change identity and account together:
await events.identifyAndWait("user_42", { email: "jane@acme.com" }, {
account: { key: "workspace_123" },
});Update contact properties on their own with setPersonProperties(set, setOnce). With the default identified_only mode, changes made before
identify stay on the device and go out with the identify call.
Call reset() on logout. It clears the identity and the account. When a
push token is registered, it first removes the device from the person who
signs out.
Accounts
events.setAccount("workspace_123");
events.setAccountProperties({ plan: "growth", seats: 12 });
events.clearAccount(); // resetAccounts() does the samesetAccount also takes an object with key, properties, and
contextToken. Pass account to the constructor when the first automatic
event must already carry it. A queued event keeps the account it was
captured with. See
Accounts and groups.
Consent
| You call | What happens |
|---|---|
optIn() | Capture is on |
optOut() | Capture stops, and the queue is cleared |
isOptedOut() | Returns true while capture is off |
Set opt_out_by_default: true to start silent until the person agrees. See
Consent and privacy.
Purchases
The client can tie an App Store, Google Play, or RevenueCat purchase to the person who made it.
| Method | What it does |
|---|---|
purchaseAttribution() | Returns the identifiers to pass to the store or to RevenueCat |
preparePurchase({ source }) | Records intent before checkout, and returns the purchase context |
observePurchase({ prepared, purchase }) | Reports the purchase that followed |
claimSubscription({ purchase, confirmation }) | Moves future revenue of an existing subscription |
The full flow, including what each store needs, is on Mobile purchase claims.
Push notifications
Register the device so FounderHQ Sequences can send it a push. There is no native module: your app asks for the permission, gets the token from its push library, and passes it.
events.registerPushToken({ token, provider: "fcm" });| Method | What it does |
|---|---|
registerPushToken({ token, provider, appId?, environment?, enabled?, permission? }) | Stores the token. provider is "apns", "fcm", or "expo" |
setPushEnabled(enabled) | Your app's own push switch for this device. false stops pushes; the device stays registered |
unregisterPushToken() | Removes the device from the person and forgets the token |
capturePushNotificationOpened(data, properties?) | Reports an open and returns the link, or null |
founderHqPushPayload(data) | Reads messageId and link from a notification's data |
The first three have an ...AndWait form that returns a promise.
The /expo entry has five more functions for expo-notifications:
| Function | What it does |
|---|---|
registerExpoPushToken(events, token, options?) | Registers an Expo push token or a native token with the correct provider |
captureExpoPushNotificationOpened(events, response, properties?) | Reports the open of a notification response and returns the link, or null |
founderHqPushDataFromExpoResponse(response) | Returns the data that holds the FounderHQ keys, or null |
dismissExpoPushNotification(Notifications, key) | Dismisses the notifications with that notification key (or message ID). Pass the expo-notifications module. Returns a promise with how many |
handleExpoPushRemoval(Notifications, notification) | Removes a notification when FounderHQ asks from your server. Resolves with true for such a push. See Replace or remove a notification |
An Expo push token uses expo. A native token uses apns on iOS and fcm
on Android:
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { registerExpoPushToken } from "@founderhq/events-react-native/expo";
registerExpoPushToken(
events,
await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas?.projectId,
}),
);The SDK always stores the token. A device is registered only for an
identified person: the SDK sends the registration after identify and until
reset(), and sends nothing for a guest. While a person is identified, the
SDK sends the registration one time on every app start, on a new token, and
when the switch or the permission changes. Your app can call
registerPushToken on every start: an unchanged call sends nothing more.
reset() removes the device from the person who signs out. It keeps the
token on the phone and clears the setPushEnabled switch. An identify for
another person, with no reset(), also clears the switch. If the phone is
offline, the SDK keeps the removal and sends it first on the next app start.
While the person is opted out, registrations wait, but a removal is still
sent.
The SDK does not store or send a token that FounderHQ refuses. It warns in the console with the reason, and never the token.
The SDK cannot read the permission state or the APNs environment. Pass
permission and, for an apns token, environment when your app knows
them. The SDK stores the permission and sends it with every later
registration, until you pass a new value.
When you leave appId out, FounderHQ uses the app ID of the event context
(context.app.id). createFounderHqExpoClient sets that ID itself. A bare
React Native client has no app ID: pass appId, or set
context: { app: { id } } when you create the client.
Report an open. The SDK reads two keys, fhqOutboundMessageId and
fhqLink, and nothing else. It sends the first as $push_message_id and
returns the second. It never opens the link.
With expo-notifications, pass the response of a tap, and the response that
started the app:
import { captureExpoPushNotificationOpened } from "@founderhq/events-react-native/expo";
const openPush = (response: Notifications.NotificationResponse | null) => {
const link = captureExpoPushNotificationOpened(events, response);
if (link) Linking.openURL(link);
};
Notifications.addNotificationResponseReceivedListener(openPush);
Notifications.getLastNotificationResponseAsync().then(openPush);captureExpoPushNotificationOpened finds the FounderHQ keys for each token
type. With an Expo push token they are in content.data. With a native
token they are in trigger.payload on iOS and in
trigger.remoteMessage.data on Android. A response that is null, or that
is not a FounderHQ notification, sends nothing.
One tap can arrive from the listener and from the cold-start call. The SDK
reports a message ID one time: a repeat sends nothing and returns null.
The full setup is on Push.
Options
Pass these in the second argument to the constructor.
| Option | Type | Default | What it does |
|---|---|---|---|
host | string | https://i.getfounderhq.com | Where events are sent |
flush_at | number | 20 | Sends a batch once this many events are queued |
flush_interval_ms | number | 10000 | Sends a batch on this timer. 0 turns the timer off |
storage | adapter | AsyncStorage | Your own storage adapter |
person_profiles | "identified_only" | "always" | "never" | "identified_only" | When contact property changes are applied |
opt_out_by_default | boolean | false | Starts opted out |
capture_lifecycle | boolean | true | App opened and backgrounded events |
autoProperties | (context: FounderHqAutoPropertiesContext) => object | null | undefined | none | Adds properties to screens, taps, and rage taps |
beforeSend | (event: FounderHqV2Event) => event | null | undefined | none | Changes or drops finished events, except purchase and push device controls |
capture_screens | boolean | true | Screen events |
capture_sessions | boolean | true | Session start events |
remote_config | boolean | true | Reads capture settings you set in FounderHQ |
context | object | none | Your own values, added to every event |
contextProvider | () => object | none | The same, resolved at capture time |
account | string | object | null | none | Installs account context before the first event |
revenueCat | object | none | Your RevenueCat client, kept in step with the identity |
purchase_prepare_timeout_ms | number | 3000 | How long preparePurchase waits before checkout goes ahead offline |
capture_element_interactions | boolean | false | Taps and rage taps. See Element interactions |
capture_element_text | boolean | true | Records the words a control shows |
capture_rageclicks | boolean | true | Rage taps, when element interactions are on |
auto_element_capture | boolean | true | Watches taps from the app root |
Lifecycle and sending
The client sends a batch when the queue reaches flush_at, on the flush
timer, and when the app goes to the background.
| Method | Returns | What it does |
|---|---|---|
flush() | Promise<boolean> | Sends queued events now |
close() | Promise<void> | Flushes, then stops the timer and the lifecycle listener |
startLifecycleCapture() | — | Re-attaches the lifecycle listener after close() |
getDistinctId() | string | The current identity |
getSessionId() | string | The current session |
Element interactions
Set capture_element_interactions to true. The SDK then records
$autocapture when someone taps a control, and $rageclick when they tap the
same control three times in a second. Both are off by default.
const events = new FounderHqReactNativeClient("fhq_pk_...", {
capture_element_interactions: true,
});You add nothing else. The SDK watches taps from the root of every screen, so it needs no code in your components. Your layout does not change, and a root wrapper of your own keeps working.
Create the client in the module your entry file imports. A client created
inside a component starts too late to watch the root. Set
auto_element_capture to false in that case, and pass
elementCaptureProps() to a view yourself.
A Modal is the one place the root cannot see. React Native gives a modal its
own window, so taps inside it never reach a view outside it. Pass
elementCaptureProps() to the modal's own view:
<Modal visible={open}>
<View style={{ flex: 1 }} {...events.elementCaptureProps()}>
<Checkout />
</View>
</Modal>Those props only watch. They never take the touch, so every control below behaves as it did before.
Each event carries the control and up to four of its parents.
| Stored | Not stored |
|---|---|
The component name, testID, and accessibilityLabel | Anything a person typed |
The accessibilityRole | Tap coordinates |
| The words the control shows | The value, placeholder, or contents of a TextInput |
A tap on a TextInput, or on any component with value, defaultValue,
placeholder, onChangeText, or secureTextEntry, records nothing at all. A
field inside a button adds nothing to that button's text, so a Save button
around an input reads as Save.
Text is cut to 255 characters. Numbers that look like a card number or a
social security number are removed from it. Add fhqNoCapture to a component
to skip it and everything inside it. Set capture_element_text to false to
record the controls without their words.
Minified builds rename components. Set testID on the controls you want to
recognize in your reports.
Remote config
The client reads your capture settings from FounderHQ at startup, caches them on the device, and applies the cached copy on the next launch. It waits up to 1.5 seconds for a fresh answer.
| Key | What it turns on and off |
|---|---|
capture_sessions | Session start events |
capture_screens | Screen events |
capture_lifecycle | App opened and backgrounded events |
autocapture | Taps and rage taps |
capture_rageclicks | Rage taps only. Taps are still recorded |
Your settings in FounderHQ win. They can turn capture off for every install, and on for an app that shipped with the wrong value. You cannot rebuild an app people have already installed, so the dashboard decides. The options you pass to the constructor apply until your settings arrive.
Events already queued under a key that turns off are dropped before they leave the device.
Call refreshRemoteConfig() to fetch them again while the app runs. Call
applyRemoteConfig(config) to apply settings you supply yourself. Set
remote_config: false to skip the request.
Next
Read the protocol reference for the wire format, the reserved event names, and the campaign properties a deep link can carry.