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/eventsOr 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.
Consent
| You call | What 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.
| Option | Type | Default | What it does |
|---|---|---|---|
mode | "events" | "pageviews" | "events" | "pageviews" sends pageviews only, and drops every other event |
host | string | https://i.getfounderhq.com | Where events are sent |
flushAt | number | 20 | Sends a batch once this many events are queued |
flushIntervalMs | number | 5000 | Sends a batch on this timer |
maxQueueSize | number | 100 | Largest queue held on the device |
storage | adapter | false | browser storage | Your 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_default | boolean | false | Starts opted out, so nothing is captured until optIn() |
respect_dnt | boolean | false | Stays off when the browser sends Do Not Track |
consent_default | "granted" | "denied" | pending | Overrides 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 |
autocapture | boolean | object | true | Click capture. The object takes urlAllowlist, urlBlocklist, selectorAllowlist, and selectorBlocklist |
capture_pageview | boolean | true | Pageviews, including single-page navigations |
capture_pageleave | boolean | true | Page exits, with time on page |
capture_sessions | boolean | true | Session start events |
capture_rageclicks | boolean | true | Repeated clicks on the same control |
capture_dead_clicks | boolean | true | Clicks that change nothing |
capture_web_vitals | boolean | true | One Core Web Vitals event per page |
capture_scroll | boolean | true | Scroll depth on the page exit event |
capture_outbound_clicks | boolean | true | Clicks on links that leave your domain |
cross_subdomain | boolean | true | Keeps one visitor across www. and app. on your domain |
cookie_domain | string | discovered | Overrides the cookie domain the SDK finds |
remote_config | boolean | true | Reads capture settings you set in FounderHQ |
beforeSend | (event) => event | null | none | Changes each event, or returns null to drop it |
account | string | object | null | none | Installs account context before the first event |
redactUrlParams | true | string[] | see below | Replaces the listed query values with [redacted] |
captureContext | boolean | true | Set false to stop sending browser, OS, screen, and page context |
tracingHeaders | boolean | string[] | false | Adds 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 |
context | object | none | Your 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
| Method | Returns | What it does |
|---|---|---|
init(key, options) | the client | Starts 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() | boolean | Tells you whether capture is off |
getDistinctId() | string | null | The current identity |
getSessionId() | string | null | The current session |
checkoutMetadata() | object | Attribution 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.