Documentation
SDKs

Web

Use the FounderHQ Web SDK.

Capture pageviews, clicks, and your own events from a browser, and tie them to a contact.

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

With npm:

npm install @founderhq/events

Or with a script tag. The script defines a global named founderhq.

<script src="https://i.getfounderhq.com/events.js"></script>
<script>
  founderhq.init("fhq_pk_XXXX");
</script>

Initialize

Call init once, as early as you can. The SDK starts automatic capture from that point.

import { founderhq } from "@founderhq/events";

founderhq.init("fhq_pk_XXXX", {
  person_profiles: "identified_only",
});

With no remembered or explicit consent choice, the SDK starts granted and records everything. A site that asks for consent first passes consent_default: "pending": the default mode then records only cookieless pageviews and pageleaves until you call consent("granted") after acceptance.

founderhq is a shared client. Call createFounderHqClient() instead when you need a second, independent client with a different write key. For the same write key, reuse the first client; see Using one SDK client across your site and packages.

Using one SDK client across your site and packages

Run one client per write key on a page. Two clients with the same key count every page twice, and they share the same storage keys.

When init succeeds, the client registers itself for its write key. getFounderHqClient(writeKey) returns that client, or null when no client uses the key yet:

import { getFounderHqClient } from "@founderhq/events";

getFounderHqClient("fhq_pk_XXXX")?.capture("newsletter.subscribed");

This matters most for a package that adds analytics to one part of a site, such as a blog. The package does not own the site's SDK setup, so it sends through the site's client instead of starting its own. It works when the site and the package bundle different copies or versions of the SDK: the registry lives on globalThis under Symbol.for("@founderhq/events/clients").

  • The result has three methods: capture, page, and register. The client can come from a different SDK version, so the type (FounderHqSharedClient) keeps only the methods every version has.
  • Look the client up when you send, not once when your code loads. The site can call init later than your code runs. In React, a child component's effects run before its parent's.
  • register adds a property to every later event on the whole site. To describe one page or one part of a page, use page <meta> tags and data-fhq-prop-* instead.
  • The first client that calls init with a key keeps the entry, unless it is a fallback client (see below). A second client with the same key still starts and captures. It logs one warning that points to getFounderHqClient.
  • destroy() and close() remove the client from the registry.
  • On the server, init registers nothing and getFounderHqClient returns null.

If your package must start its own client because the site has none yet, pass yield_to_site_client: true to init. When the site's client later starts with the same write key, your client sends what it has queued, stops, and hands over. The site's client keeps the same visitor and session, and it logs no warning. It also continues the page that is open at that moment instead of sending a second pageview for it, so every page is counted once. If a client with the key is already running, a client with this option does not start at all, and logs nothing.

getFounderHqClient(writeKey, { includeFallback: false }) ignores such a fallback client, so it tells you whether the site runs its own client.

A blog built with @founderhq/next-blog works this way: it reuses your site's client, and starts its own only when there is none. See Next.js blog.

Capture events

founderhq.capture("trial.started", { plan: "growth" });

track is an alias for capture. Both queue the event and return at once. The SDK sends it with the next batch after consent is granted; custom events called while consent is pending or denied are ignored.

Awaiting readyForCapture() first tells you that stored state is loaded. Use it when a test or a redirect must not race the queue.

Pageviews, pageleaves, sessions, semantic clicks, rage clicks, dead clicks, outbound clicks, scroll depth, and Core Web Vitals are captured for you. Turn each one off with its own option below.

Click capture stores semantic metadata and the clicked element's own label (its direct text, such as "Start free trial"), so a click reads as what the person pressed. It never stores input values, a child element's text, page contents, or click coordinates. Set captureElementText: false to store no label at all. Add data-fhq-no-capture or the class fhq-no-capture to keep a subtree out of capture entirely. Use data-fhq-prop-* to add top-level click properties for goals and filters; see Adding properties to automatic events. To capture clicks in one part of a page only, see Capturing clicks in part of a page.

Call page() to send a pageview yourself, and screen(name, properties) for an app-like screen view.

Pageview properties

Each pageview reads the page's title, language, canonical URL, and preferred color scheme at capture time:

PropertyValue
$titledocument.title, trimmed; the site-name suffix is kept
$document_languageThe HTML element's lang attribute
$canonical_urlThe canonical link's resolved href
$prefers_color_scheme"dark" or "light" from the browser's preference

Empty titles and languages, missing canonical links, and color scheme when matchMedia is unavailable are omitted. The matching pageleave keeps these values from its pageview, even after a single-page navigation updates the document.

The SDK also reads 19 click IDs from the page URL, such as gclid, fbclid, msclkid, ttclid, and li_fat_id. The full list is in Event taxonomy. Like utm_*, they keep their query key as the property name. They are read per page, not saved as a first-touch value.

Pageviews in a single-page app

The SDK sends a pageview for each pushState, replaceState, and popstate that changes the URL. A call that keeps the same URL sends nothing. Next.js calls replaceState on every router commit, and router.refresh() and revalidation do the same, so these are not counted as new pages. A change to the hash alone (#pricing) is a new URL and counts as a new page.

The SDK sends a pageleave when the tab goes to the background ($leave_reason: "hidden"), because a browser can close a background tab without warning. If the visitor comes back to the tab, the page is not finished. When they leave it, the SDK sends another pageleave with the same $pageview_id and the whole $page_duration_ms. Use the longest pageleave of each pageview.

A router often changes the URL before it shows the new page: during a loading state, or on back and forward. So the SDK sends the pageleave of the old page at once, but waits for the new page before it reads $title and the page's meta tags. It sends the pageview when one of these happens first:

  • <head> holds an fhq-page-path tag for the new path. See Describe a page with meta tags.
  • The title changes, when the old page had no fhq-page-path tag.
  • One second passes.

The pageview keeps the time of the navigation. A second navigation, or the visitor leaving, sends a waiting pageview at once, with what the page shows. Your first pageview and your own page() calls do not wait.

Adding properties to automatic events

Use auto_properties for $pageview, $autocapture, $rageclick, $dead_click, and $outbound_click:

founderhq.init("fhq_pk_XXXX", {
  auto_properties: ({ pathname, element }) => ({
    section: pathname.startsWith("/docs") ? "docs" : "website",
    ...(element ? { control: element.tagName.toLowerCase() } : {}),
  }),
});

The callback receives event, url, pathname, and element. element is the clicked DOM element, or undefined for a pageview. Import the FounderHqAutoPropertiesContext type if you define the callback separately. It runs on the initial pageview, each pushState, replaceState, and popstate navigation that changes the URL (when that pageview is sent), every page() call, and each click-family event at capture time. Return an object, or null or undefined to add nothing. If it throws, the event still sends and the SDK warns once per client. Remote config updates keep your callback.

For clicks, you can also put properties directly on an element:

<section data-fhq-prop-product-id="starter">
  <button data-fhq-prop-action="buy">Buy</button>
</section>

The click gets top-level product_id: "starter" and action: "buy" properties that you can use in goals and filters. The SDK checks the clicked element and every ancestor through document.body. The nearest value wins. Hyphens in keys become underscores; values stay strings. These attributes also stay in the existing $elements metadata. The no-capture attribute and class still suppress the whole subtree.

Describe a page with meta tags

For pageviews, a page can describe itself with <meta name="fhq-prop-<key>"> tags:

<meta name="fhq-prop-blog-article" content="pricing-guide" />
<meta name="fhq-prop-blog-author" content="Jane Doe" />

The pageview gets blog_article: "pricing-guide" and blog_author: "Jane Doe". Hyphens in the key become underscores, and the value is the content attribute. An empty or blank content adds nothing. Use each name once: if a name repeats, the first tag wins. The tags apply to pageviews only, not to clicks. The matching pageleave keeps them.

The SDK reads tags in <head> only. It reads them when it captures the pageview, at the same moment it reads $title.

In a single-page app, also tell the SDK which page the tags belong to:

<meta name="fhq-page-path" content="/blog/pricing-guide" />
<meta name="fhq-prop-blog-article" content="pricing-guide" />

When <head> has an fhq-page-path tag, the SDK uses the fhq-prop-* tags only for a pageview whose path matches one of them. It compares the path alone: the query and the hash are ignored, and a trailing slash does not matter. The SDK does not send fhq-page-path as a property. Without it, the tags apply to every pageview, which is right for a site that loads each page in full.

This matters because the old page's tags can stay in the document after a client-side navigation:

  • During a loading state, or on back and forward, the old page's tags stay in <head> until the new page renders.
  • React 19 moves a <meta> that a component renders into <head>, but it leaves the server-rendered copy in <body>. That is why the SDK reads <head> only.

With fhq-page-path, the SDK also knows when the new page has rendered. See Pageviews in a single-page app.

Next.js

Render the <meta> tags in the page component. Do not rely on generateMetadata for them: Next.js streams its output, so it can arrive after the pageview.

Order and limits

Later values win in this order: automatic properties → auto_properties → data-fhq-prop-* on clicks, or fhq-prop-* meta tags on pageviews → explicit page(properties) values. A pageleave keeps its pageview's merged properties without calling the callback again.

All three 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, without splitting an emoji or other character in two; other values are dropped. Each event keeps up to 20 extra keys: nearest attributes (or meta tags, in document order) first, then callback keys in object-entry order. Explicit page() and capture() properties keep their existing behavior.

All three sources follow document metadata: captureContext: false or an injected platformFactsProvider skips them. The callback runs only in the browser, including cookieless mode. The cookieless allowlist still applies afterward: pageviews can carry pathname, referring domain, the five UTM keys, the page's language tag ($document_language), and platform. Ad click IDs, title, canonical URL, color scheme, and arbitrary custom properties, including meta tag properties, are excluded. Cookieless mode captures no clicks.

auto_properties adds properties to these automatic events and receives the clicked element. It cannot drop or rename an event. beforeSend runs afterward and sees every finished event; it can change the event or return null to drop it. Session starts, Web Vitals, identity changes, screen views, and your own capture() calls do not call auto_properties.

Capturing clicks in part of a page

Add data-fhq-autocapture to a container to capture clicks inside it. This works even when you pass autocapture: false to init, or when its allowlists leave the container out.

<aside data-fhq-autocapture data-fhq-prop-area="blog-sidebar">
  <a href="/blog/pricing-guide">Pricing guide</a>
</aside>

A click on a control inside the container sends $autocapture. It also sends $rageclick, $dead_click, and $outbound_click when they apply.

  • The clicked control must be inside the container. A button that wraps the container does not count.
  • data-fhq-autocapture="false" turns it off for a nested part. The nearest attribute wins.
  • data-fhq-no-capture, the class fhq-no-capture, urlBlocklist, and selectorBlocklist still win.
  • capture_rageclicks, capture_dead_clicks, capture_outbound_clicks, and captureElementText still apply.
  • data-fhq-prop-* and auto_properties apply as they do to other clicks.
  • Consent and opt-out still apply. Cookieless mode captures no clicks.
  • The container overrides only the autocapture option you pass to init. A remote config autocapture: false turns these clicks off too, and removes the ones still queued.
  • mode: "pageviews" sends pageviews only, with or without the attribute. A package that wants these clicks needs a client in the default "events" mode.

With autocapture: false, the SDK still listens for clicks. A click outside every container stops after one DOM lookup. The observer that detects dead clicks starts only at the first click inside a container.

A dead click is a click that nothing answered within one second: no page change, no navigation, no form submit, no focus or state change, and no scroll. A table-of-contents button that only scrolls the page is not a dead click.

Name an event for people

An event name is an identifier: FounderHQ shows it exactly as you wrote it, and never rewords it. Send $display_name when you want a label read instead:

founderhq.capture("trial.started", {
  $display_name: "Trial started",
  plan: "growth",
});

The label is remembered against the event name, so sending it once is enough, and a later event without it does not erase it. Send it again to change it. Keep it short: it is a row label, not a sentence.

This works from any SDK, including the Node one, because it is an ordinary event property.

Identify a contact

founderhq.identify("user_42", { email: "jane@acme.com" });

Call this after consent is granted. identify never changes consent. Everything captured after consent but before identify stays attached to the same person. Pass an account in the third argument to change identity and account together:

founderhq.identify("user_42", { email: "jane@acme.com" }, {
  account: { key: "workspace_123", properties: { plan: "growth" } },
});

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.

Repeating identify for the same ID is safe: the first call records the identification, later calls only update properties, and a call with nothing new sends nothing. name and avatarUrl fill the contact's name and photo; see Identity and contacts.

Call reset() on logout. It clears the identity, the account, and the session. When the browser is registered for web push, reset() first removes it from the contact who signs out.

Accounts

An account is the company or workspace a contact belongs to.

founderhq.setAccount("workspace_123");
founderhq.setAccountProperties({ plan: "growth", seats: 12 });
founderhq.clearAccount();

setAccount also takes an object with key, properties, and contextToken. Pass account to init when the very first event must already carry it. See Accounts and groups.

You callWhat happens
optIn()Capture is on, and the choice is remembered
optOut()Capture stops, and queued data is cleared
consent("granted")Same as optIn()
consent("denied")The SDK stops writing identity and stores nothing
isOptedOut()Returns true after optOut(), with opt_out_by_default, or when Do Not Track is honored. It is false while consent is pending or denied

Set opt_out_by_default: true to start silent until the visitor agrees. Set respect_dnt: true to stay off when the browser sends Do Not Track. The SDK starts granted when there is no remembered or explicit choice. Set consent_default: "pending" when your site asks for consent first, or "denied" to start in the denied state. In both, the default mode records only cookieless pageviews and pageleaves.

getDistinctId() and getSessionId() return null while consent is not granted. See Consent and privacy.

Checkout attribution

Attach these values to a Stripe or Dodo checkout so the payment keeps its attribution.

const metadata = founderhq.checkoutMetadata();
const clientReferenceId = await founderhq.paymentLinkToken();

checkoutMetadata() returns fhq_anonymous_id and fhq_session_id, plus fhq_account_key and fhq_account_context when an account is set. paymentLinkToken() returns one opaque string for Stripe Payment Links and Pricing Tables, or null when consent is not granted. See Checkout metadata and payment links.

Web push

Register the browser so FounderHQ Sequences can send it a notification. Your site asks for the permission and hosts one service worker file. The SDK does the rest. The setup is on Web push.

The methods are registerWebPush, requestWebPushPermission, setPushEnabled, dismissPushNotification, and unregisterPushToken.

founderhq.identify("user_42");
const result = await founderhq.registerWebPush({
  applicationServerKey: "YOUR_PUBLIC_KEY",
});
MethodWhat it does
registerWebPush({ applicationServerKey, serviceWorkerPath?, serviceWorkerScope?, serviceWorkerRegistration? })Subscribes the browser and stores the subscription. Returns a promise with { status, reason? }
requestWebPushPermission(options)Shows the browser's permission prompt, then does what registerWebPush does. Call it from a click on your own button. Same options and result
setPushEnabled(enabled)Your site's own push switch for this browser. false stops pushes; the browser stays registered
dismissPushNotification(key)Closes the notifications your FounderHQ pushes showed with that notification key (or message ID), in this browser. Returns a promise with how many it closed. See Replace or remove a notification
unregisterPushToken()Removes the browser from the contact, forgets the subscription, and ends that subscription in the browser. Returns a promise

registerWebPush never shows the permission prompt. It does nothing until Notification.permission is granted, so you can call it on every page load. requestWebPushPermission shows the prompt: call it only from a click, because a browser refuses a prompt that no click started. It does not ask again when the person already allowed or blocked notifications. serviceWorkerPath defaults to /founderhq-push-sw.js.

The SDK does not replace a service worker your site already has. With no serviceWorkerPath, it then returns failed with the reason service_worker_exists. Load founderhq-push-sw.js from the first line of your worker and give its path. Or give serviceWorkerRegistration when your site registers the worker itself. See If your site already has a service worker.

status is registered, not_identified, permission_not_granted, unsupported, consent_required, or failed. The promise never rejects. With failed, reason is one of these:

ReasonMeaning
not_initializedYou called it before init
invalid_application_server_keyThe key is not the public key of a web push connection
service_worker_existsYour site has its own service worker and you gave no serviceWorkerPath
service_worker_register_failedThe worker file did not register or did not become active. Make sure that your site serves it
subscribe_failedThe browser refused the subscription
other_push_subscriptionThe worker already has a push subscription from another push product. The SDK does not end it. A worker has only one subscription, so the SDK cannot add its own
invalid_push_endpointThe subscription does not point at a known push service

The SDK only ends a push subscription that it made. A subscription from another push product on your site always stays.

setPushEnabled is kept in the SDK's storage. A call before init, in cookieless mode, or while consent is not granted does nothing. Call it again after init and after consent is granted. Before a contact is identified, the SDK stores the switch and sends it with the next registration.

A browser is registered only for an identified contact. The SDK sends the registration after identify and until reset(), and sends nothing for a guest. It sends it one time in each session, and again when the subscription, the contact, or setPushEnabled changes. A page load in a new session sends it from the stored subscription, also when the page does not call registerWebPush.

reset() removes the browser from the contact who signs out. It keeps the subscription in the browser and clears the setPushEnabled switch. If the browser is offline, the SDK keeps the removal and sends it first on the next page load. If the same contact signs in again before that, the SDK cancels the removal.

The SDK stores the subscription in the browser's storage, so web push needs analytics consent. In cookieless mode, or while consent is not granted, registerWebPush returns consent_required and stores and sends nothing.

When a visitor refuses consent later (consent("denied")), the SDK clears its storage and cannot send a removal. It ends its own push subscription in the browser instead, so the browser gets no more pushes.

optOut() is different. It stops everything that the SDK sends and clears its storage, but the browser stays subscribed and continues to get pushes. Use it for a person who must not be in your analytics, such as your staff. To stop pushes too, call unregisterPushToken() before optOut().

unregisterPushToken() also works without consent. It sends nothing then, and the SDK has no stored subscription. It ends only a subscription that is its own: one on the founderhq-push-sw.js worker, or one made with the applicationServerKey that the page gave to registerWebPush on this page load. So call registerWebPush first on that page; without consent it returns consent_required and only remembers the key.

When a person opens a notification, the service worker adds fhq_push to a link on your own site. The SDK reads it on page load, sends $push_notification_opened, and removes it from the address bar. You write no code for this. The open is counted only when the person is identified on the page that opens.

Options

Pass these in the second argument to init.

OptionTypeDefaultWhat it does
mode"events" | "pageviews""events""pageviews" sends pageviews only, and drops every other event
hoststringhttps://i.getfounderhq.comWhere events are sent
flushAtnumber20Sends a batch once this many events are queued
flushIntervalMsnumber5000Sends a batch on this timer
maxQueueSizenumber100Largest queue held on the device
storageadapter | falsebrowser storageYour own storage adapter, or false to keep the queue in memory
person_profiles"identified_only" | "always" | "never""identified_only"When contact property changes are applied
opt_out_by_defaultbooleanfalseStarts opted out, so nothing is captured until optIn()
respect_dntbooleanfalseStays off when the browser sends Do Not Track
consent_default"granted" | "pending" | "denied""granted"The consent state on a first visit. A remembered answer wins over it
cookieless_mode"off" | "always" | "when_not_granted" | "on_reject""when_not_granted"Chooses cookieless behavior before and after the consent choice
autocaptureboolean | objecttrueClick capture. The object takes urlAllowlist, urlBlocklist, selectorAllowlist, and selectorBlocklist. A data-fhq-autocapture container captures clicks even when you set this to false; a remote config false stops them too
capture_pageviewbooleantruePageviews, including single-page navigations
auto_properties(context: FounderHqAutoPropertiesContext) => Record<string, unknown> | null | undefinednoneAdds properties to pageviews and click-family events; click attributes, page meta tags, and explicit page() properties win
capture_pageleavebooleantruePage exits, with time on page
capture_sessionsbooleantrueSession start events
capture_rageclicksbooleantrueRepeated clicks on the same control
capture_dead_clicksbooleantrueClicks that change nothing
capture_web_vitalsbooleantrueOne Core Web Vitals event per page
capture_scrollbooleantrueScroll depth on the page exit event
capture_outbound_clicksbooleantrueClicks on links that leave your domain
cross_subdomainbooleantrueKeeps one visitor across www. and app. on your domain
cookie_domainstringdiscoveredOverrides the cookie domain the SDK finds
remote_configbooleantrueReads capture settings you set in FounderHQ
beforeSend(event) => event | nullnoneRuns after automatic properties; changes each finished event, or returns null to drop it
accountstring | object | nullnoneInstalls account context before the first event
yield_to_site_clientbooleanfalseFor a package's fallback client: hands over to the site's client when it starts with the same key; does not start if one is running
redactUrlParamstrue | string[]see belowReplaces the listed query values with [redacted]
captureContextbooleantrueSet false to skip browser context, document metadata, auto_properties, data-fhq-prop-*, and page meta tags
tracingHeadersboolean | string[]falseAdds the session id to your own API calls, so backend events join the visit. true covers same-origin requests; pass origins for a separate API host
contextobjectnoneYour own values, added to every event
captureElementTextbooleantrueSet false to leave the text of clicked elements out
autoFlushbooleantrueSet false to send only when you call flush()
queueTtlMsnumber24 hoursHow long an unsent event may wait before it is dropped
compressfunction | falsegzipSet false to send batches uncompressed

redactUrlParams redacts token, access_token, auth, code, password, secret, and key even when you leave it out. Pass an array to replace that list with your own.

tracingHeaders wraps fetch and XMLHttpRequest so every request to your own origin carries x-founderhq-session-id. Your backend reads that header and passes it to the Node SDK, and the events it sends then sit inside the visit that caused them. See Join backend events to a visit.

Methods

MethodReturnsWhat it does
init(key, options)the clientStarts the SDK. Later calls do nothing
capture(event, properties)—Queues one event
track(event, properties)—Alias for capture
readyForCapture()Promise<void>Resolves once stored state is loaded
identify(distinctId, properties, options)—Ties the visitor to a contact
setAccount(account)—Sets the active account
clearAccount()—Removes the active account
setAccountProperties(properties)—Updates the active account
setPersonProperties(set, setOnce)—Updates contact properties
screen(name, properties)—Records a screen view
page(properties)—Records a pageview
register(properties)—Adds properties to every later event
registerOnce(properties)—Same, but keeps an existing value
unregister(key)—Removes one registered property
optIn() / optOut()—Turns capture on or off
consent(state)—Sets "granted" or "denied"
isOptedOut()booleanTells you whether capture is switched off entirely
getDistinctId()string | nullThe current identity
getSessionId()string | nullThe current session
checkoutMetadata()objectAttribution fields for a checkout. An empty object until consent is granted
paymentLinkToken()Promise<string | null>A reference for Stripe Payment Links
reset()—Clears identity, account, and session. Removes a web push browser from the contact
registerWebPush(options)Promise<{ status, reason? }>Subscribes the browser to web push
setPushEnabled(enabled)—Turns pushes to this browser on or off
dismissPushNotification(key)Promise<number>Closes the FounderHQ notifications with that key
unregisterPushToken()Promise<void>Removes the browser from the contact and unsubscribes it
flush()Promise<boolean>Sends queued events now
close()Promise<void>Sends the page exit event, flushes, and stops
destroy()—Stops the client and removes its listeners, without sending
applyRemoteConfig(config)Promise<void>Applies capture settings you supply yourself

getFounderHqClient(writeKey, options) is a separate export, not a method. It returns the page's initialized client for that key, or null. Pass { includeFallback: false } to ignore a yield_to_site_client client. See Using one SDK client across your site and packages.

Remote config

The SDK reads your capture settings from FounderHQ at startup, so you can turn capture on or off without a redeploy. It caches the last answer in the browser and uses it on the next load.

On a first visit the SDK waits up to 1.5 seconds for those settings before it arms automatic capture. A slow answer never costs you the landing pageview. Settings that arrive later re-arm capture, and remove queued events they disable.

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 the SDK reads from a URL.

AI agent or LLM? Read this page as markdown

On this page