# Web (https://www.getfounderhq.com/docs/analytics/sdks/web)

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

<Callout title="Before you start">
You need a publishable key (`fhq_pk_...`) for the brand you want to
measure. See [Getting started](/analytics/getting-started).
</Callout>

## Install

With npm:

```npm
npm install @founderhq/events
```

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

```html
<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.

```ts
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

```ts
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:

```ts
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

```ts
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:

```ts
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](/analytics/concepts/identity-and-contacts#properties-founderhq-understands).

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.

```ts
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](/analytics/concepts/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](/analytics/concepts/consent-and-privacy).

## Checkout attribution

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

```ts
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](/analytics/revenue/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](/analytics/recipes/session-stitching).

## 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](/analytics/protocol-reference) for the wire
format, the reserved event names, and the campaign properties the SDK
reads from a URL.
