Documentation
SDKs

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.

Nothing is sent anywhere. A publishable key is saved in this browser. A secret key is kept only until you reload.

Install

npm install @founderhq/events-react-native @react-native-async-storage/async-storage

The 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-localization

Initialize

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, and options.process_person_profile are retained. Other envelope and options keys, including cookieless_mode, are stripped.
  • event is 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.
  • uuid must 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 custom uuid option: with a beforeSend, an event whose id is not an RFC 4122 UUID is dropped.
  • distinct_id is trimmed and must contain 1–400 characters and pass the SDK's illegal-distinct-ID check. The trimmed value is queued.
  • timestamp must be a real calendar date-time in RFC 3339 form: YYYY-MM-DDTHH:MM:SS, optional fractional seconds, then Z or ±HH:MM. Whole seconds are valid; impossible dates such as 30 February are rejected.
  • properties must serialize to a JSON object, and options.process_person_profile must be a boolean.
  • Optional session_id is kept only if it is a UUIDv7 with variant 8–b. Optional window_id is 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 same

setAccount 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.

You callWhat 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.

MethodWhat 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" });
MethodWhat 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:

FunctionWhat 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.

OptionTypeDefaultWhat it does
hoststringhttps://i.getfounderhq.comWhere events are sent
flush_atnumber20Sends a batch once this many events are queued
flush_interval_msnumber10000Sends a batch on this timer. 0 turns the timer off
storageadapterAsyncStorageYour own storage adapter
person_profiles"identified_only" | "always" | "never""identified_only"When contact property changes are applied
opt_out_by_defaultbooleanfalseStarts opted out
capture_lifecyclebooleantrueApp opened and backgrounded events
autoProperties(context: FounderHqAutoPropertiesContext) => object | null | undefinednoneAdds properties to screens, taps, and rage taps
beforeSend(event: FounderHqV2Event) => event | null | undefinednoneChanges or drops finished events, except purchase and push device controls
capture_screensbooleantrueScreen events
capture_sessionsbooleantrueSession start events
remote_configbooleantrueReads capture settings you set in FounderHQ
contextobjectnoneYour own values, added to every event
contextProvider() => objectnoneThe same, resolved at capture time
accountstring | object | nullnoneInstalls account context before the first event
revenueCatobjectnoneYour RevenueCat client, kept in step with the identity
purchase_prepare_timeout_msnumber3000How long preparePurchase waits before checkout goes ahead offline
capture_element_interactionsbooleanfalseTaps and rage taps. See Element interactions
capture_element_textbooleantrueRecords the words a control shows
capture_rageclicksbooleantrueRage taps, when element interactions are on
auto_element_capturebooleantrueWatches 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.

MethodReturnsWhat 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()stringThe current identity
getSessionId()stringThe 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.

StoredNot stored
The component name, testID, and accessibilityLabelAnything a person typed
The accessibilityRoleTap coordinates
The words the control showsThe 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.

KeyWhat it turns on and off
capture_sessionsSession start events
capture_screensScreen events
capture_lifecycleApp opened and backgrounded events
autocaptureTaps and rage taps
capture_rageclicksRage 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.

AI agent or LLM? Read this page as markdown

On this page