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.

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 pending. The default mode 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.

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 only. It never stores visible text, input values, page contents, or click coordinates. Add data-fhq-no-capture or the class fhq-no-capture to keep a subtree out of capture entirely.

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

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.

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 while capture is off

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. Set consent_default: "denied" to start in the denied state. If there is no remembered or explicit choice, the SDK starts pending and the default cookieless_mode: "when_not_granted" records cookieless page traffic.

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.

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" | "denied"pendingOverrides the consent state on a first visit
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
capture_pageviewbooleantruePageviews, including single-page navigations
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 | nullnoneChanges each event, or returns null to drop it
accountstring | object | nullnoneInstalls account context before the first event
redactUrlParamstrue | string[]see belowReplaces the listed query values with [redacted]
captureContextbooleantrueSet false to stop sending browser, OS, screen, and page context
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

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 off
getDistinctId()string | nullThe current identity
getSessionId()string | nullThe current session
checkoutMetadata()objectAttribution fields for a checkout
paymentLinkToken()Promise<string | null>A reference for Stripe Payment Links
reset()Clears identity, account, and session
flush()Promise<boolean>Sends queued events now
close()Promise<void>Sends the page exit event, flushes, and stops
applyRemoteConfig(config)Promise<void>Applies capture settings you supply yourself

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