@founderhq/events
v1.5.1
Published
FounderHQ browser and mobile event ingest SDK
Readme
FounderHQ Events SDK 1.5.1
Web event capture for FounderHQ Events, Contacts, Segments, and Sequences.
import { founderhq } from "@founderhq/events";
founderhq.init("fhq_pk_client_key", {
host: "https://i.getfounderhq.com",
person_profiles: "identified_only",
});
founderhq.identify("user_123", { email: "[email protected]" });
founderhq.capture("signup.completed", { signupSource: "website" });
const checkoutMetadata = founderhq.checkoutMetadata();checkoutMetadata returns fhq_anonymous_id and fhq_session_id for
Stripe/Dodo checkout metadata so later ledger events retain attribution.
Consent
A visitor starts with consent granted: everything is recorded from the
first page view. A site that asks for consent first starts pending and
grants on acceptance:
founderhq.init("fhq_pk_client_key", { consent_default: "pending" });
// Later, when the visitor accepts your banner:
founderhq.consent("granted");consent_default accepts granted (default), pending, and denied. A
visitor's remembered answer always wins over it. opt_out_by_default: true
and respect_dnt: true start with capture off entirely.
For a Stripe Checkout Session, copy both values to Session metadata and to the object that survives the checkout:
const metadata = founderhq.checkoutMetadata();
// One-time payment
stripe.checkout.sessions.create({
mode: "payment",
metadata,
payment_intent_data: { metadata },
// ...
});
// Subscription
stripe.checkout.sessions.create({
mode: "subscription",
metadata,
subscription_data: { metadata },
// ...
});Session metadata alone does not flow to subscription renewals. FounderHQ uses the first attributed payment to bind the Stripe customer/subscription or Dodo subscription, then evaluates each renewal against the attribution window at the renewal time.
For Dodo checkout creation, pass the same metadata object. For Stripe Payment
Links or Pricing Tables, mint an opaque server-resolved reference and set it as
client_reference_id:
const clientReferenceId = await founderhq.paymentLinkToken();The token contains no contact or session identifiers and is requested without
browser keepalive.
While consent is pending or denied, the default
cookieless_mode: "when_not_granted" captures only personless $pageview
and $pageleave measurement. With consent granted, which is the default,
safe automatic capture includes pageviews/pageleave (with
0-100 $max_scroll_percentage/$last_scroll_percentage scroll depth),
sessions, semantic control clicks, rage/dead clicks, $outbound_click for
links leaving the registrable domain ($outbound_url keeps campaign params
only), and one Core Web Vitals event per page. Click capture records
semantic metadata and the clicked element's own label (its direct text, off
with captureElementText: false); it never records input values, a child
element's text, page contents, coordinates, or arbitrary DOM snapshots.
After consent is granted, identity, consent, and the session are mirrored into
first-party cookies on the registrable domain (discovered automatically), so
visitors keep one identity and attribution chain across www. and app.
subdomains. Override the domain with cookie_domain or disable with
cross_subdomain: false.
cookieless_mode accepts off, always, when_not_granted (default), and
on_reject. on_reject is silent while consent is pending. identify() never
changes consent, and optOut() is a hard off switch in every mode.
At init the SDK also fetches operator-set capture overrides from
GET /i/v1/analytics/config (cached in localStorage for synchronous reuse),
so capture toggles can change without customers redeploying. Disable with
remote_config: false.
founderhq.init("fhq_pk_client_key", {
autocapture: {
urlBlocklist: ["/billing/private"],
selectorBlocklist: [".sensitive-action"],
},
capture_dead_clicks: false,
respect_dnt: true,
beforeSend: (event) => event.event === "internal.debug" ? null : event,
});Autocaptured elements carry their visible label as text, so a click reads as
"Start free trial" rather than as an anonymous button. Only the element's own
text nodes are read, never a child's, so a nested field's contents cannot ride
out inside its container. Inputs, selects, textareas, and contenteditable
regions never contribute text at all, numbers that look like a card number or
a social security number are dropped — including one written with spaces or
dashes — and the result is truncated to 255 characters. Text nodes are joined
with a space, so Pay with <span>card</span> now reads as "Pay with now".
Turn it off with captureElementText: false.
The scrubbing rules, the length limit, and the rage-click rule live in
@founderhq/events-core, shared with the React Native SDK and generated into
the iOS and Android SDKs, so all four report the same things the same way.
Add data-fhq-no-capture or .fhq-no-capture to suppress a subtree. Explicit
data-fhq-* attributes are recorded as element metadata; use
data-fhq-prop-* to also add top-level click properties (see below).
optOut() persists consent and
clears queued data; opt_out_by_default requires optIn() before capture.
Person and super-property APIs:
founderhq.setPersonProperties({ plan: "pro" }, { firstPlan: "pro" });
founderhq.register({ appArea: "dashboard" });
founderhq.registerOnce({ firstAppArea: "dashboard" });
founderhq.unregister("appArea");With the default identified_only mode, pre-identify person changes remain on
the device and are atomically included in $identify. always updates guest
profiles immediately; never stitches events to identity without mutating the
profile. $set wins over $set_once, and standalone property calls cannot
replace email, phone, external ID, or distinct ID.
The hosted browser bundle is generated from this package:
<script src="https://i.getfounderhq.com/events.js"></script>
<script>
founderhq.init("fhq_pk_client_key");
founderhq.capture("signup.completed");
</script>Managed Publish keys use { mode: "pageviews" }, which restricts the client
to anonymous $pageview capture. Normal Events keys use the default mode and
support anonymous custom events after consent but before identify(); those
events are attached to a brand-scoped guest and stitched to the person on
identification.
The canonical endpoint is POST /i/v2/e. It accepts a
snake-case { sent_at, batch } envelope and the publishable key in
Authorization: Bearer. Each batch item receives an independent ok,
warning, drop, or retry result keyed by its UUID.
There is no v1 event-ingest compatibility route: this SDK was greenfield when
v2 became the sole browser/mobile ingest protocol.
Protocol v2 has no context object. Automatic facts live in properties
under the canonical $ taxonomy, while all 24 campaign keys remain
unprefixed. Sessions are client-generated UUIDv7. $pageleave remains the
authoritative engagement record, and the next $pageview carries the prior
page's $prev_pageview_* fallback so hard unloads do not erase engagement.
Pageviews also carry $title (trimmed, with the site-name suffix intact),
$document_language from the HTML lang attribute, $canonical_url from the
canonical link's resolved href, and $prefers_color_scheme (dark or
light). Empty title/language and missing canonical links are omitted;
color scheme is omitted when matchMedia is unavailable. These values are
read for each pageview and retained for that page's $pageleave.
Pageviews in a single-page app
A pageview is sent for each pushState, replaceState, and popstate
whose resulting location.href differs from the page's URL. A call that
keeps the same URL sends no pageleave and no pageview: Next.js calls
replaceState on every router commit, and router.refresh(), server-action
revalidation, and back/forward do the same. A hash-only change is a new
URL, so it still counts as a navigation.
At the navigation the SDK sends the old page's $pageleave at once and
takes the new page's time and $pageview_id. It waits to read $title and
the page's meta tags until the new page has rendered, and sends the
$pageview (timestamped at the navigation) as soon as:
<head>holds anfhq-page-pathtag equal to the new path, or- the title changes, when the page it left had no
fhq-page-pathtag, or - 1000 ms pass (
PAGEVIEW_SETTLE_TIMEOUT_MS).
A second navigation, a pagehide, a page hidden, close(), or a page()
call sends a waiting pageview first, with what the page shows. The wait
uses one MutationObserver on <head>, disconnected when the pageview is
sent. The initial pageview and explicit page() calls do not wait; nor do
clients with captureContext: false or platformFactsProvider, which read
no document properties (they send in a microtask, as before).
Campaign properties are read from that page's URL, with no first-touch
persistence. They include gclid, gbraid, wbraid, dclid, fbclid,
msclkid, ttclid, twclid, li_fat_id, and rdt_cid, using the raw query
keys just like utm_*.
Adding properties to automatic events
Use auto_properties to add properties to $pageview, $autocapture,
$rageclick, $dead_click, and $outbound_click:
founderhq.init("fhq_pk_client_key", {
auto_properties: ({ pathname, element }) => ({
section: pathname.startsWith("/docs") ? "docs" : "website",
...(element ? { control: element.tagName.toLowerCase() } : {}),
}),
});The exported FounderHqAutoPropertiesContext contains event, url,
pathname, and an optional element: the clicked DOM element, undefined
for pageviews. The callback runs on the initial pageview, each
URL-changing pushState/replaceState/popstate navigation (when its
pageview is sent, after the settle wait), every page() call, and
when each click-family event is captured. Return an object, null, or
undefined. A thrown error logs one warning per client and does not stop
capture. Remote config updates keep the callback.
For clicks, add data-fhq-prop-<key> attributes to the clicked element or
any ancestor through document.body:
<section data-fhq-prop-product-id="starter">
<button data-fhq-prop-action="buy">Buy</button>
</section>These become top-level product_id: "starter" and action: "buy"
properties, usable in goals and filters. Hyphens become underscores and
values stay strings. The nearest element wins when ancestors use the same
key. The existing $elements metadata still includes these attributes.
data-fhq-no-capture and .fhq-no-capture suppress the whole subtree.
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 become underscores, and the value is the
content attribute. A missing, empty, or blank content adds nothing. If
a name repeats, the first tag in document order wins. The tags apply to
pageviews only, not to clicks, and the page's $pageleave keeps them.
Tags are read from document.head only, at the same instant as $title
(after a client-side navigation, once the page has settled; see
"Pageviews in a single-page app"). React 19 hoists a client copy of a
<meta> into <head> but never removes the server-rendered copy in
<body>, so a <body> tag can belong to a page the visitor already left.
A page can scope its tags with <meta name="fhq-page-path" content="<pathname>">:
<meta name="fhq-page-path" content="/blog/pricing-guide" />
<meta name="fhq-prop-blog-article" content="pricing-guide" />When <head> holds one or more fhq-page-path tags, the fhq-prop-* tags
apply to a pageview only if one of them equals the pageview's pathname
(the query and hash are ignored; a trailing slash is equal). With none,
the tags apply to every pageview, as on a static site or with the hosted
script. fhq-page-path is never sent as a property. It keeps a previous
page's tags that are still in <head> (a loading state, a back/forward
that commits in a later task) from describing the next page, and it tells
the settle wait when the new page has rendered. Next.js streams
generateMetadata output, which can arrive late: render the <meta> tags
directly in the page component instead.
Merge order, with later values winning: automatic properties →
auto_properties → data-fhq-prop-* (clicks) or fhq-prop-* meta tags
(pageviews) → explicit page(properties) values. A $pageleave retains
its pageview's merged properties; it never calls the callback again.
All three sources use the same limits. Keys must contain 1–64 characters
and must not start with $. Values must be strings, finite numbers, or
booleans. Strings are trimmed and cut to 200 characters without splitting
a surrogate pair; other values are dropped. At most 20 extra keys survive
per event: nearest attributes (in attribute order) or meta tags (in
document order) first, then callback keys in object-entry order. These
limits do not change explicit page(properties) or capture() values.
Like document metadata, all three sources are skipped with
captureContext: false or an injected platformFactsProvider. The
callback only runs in the browser. It also runs in cookieless mode, before
the shared allowlist: only pathname, referring domain, the five UTM keys,
the page's language tag ($document_language), and platform can be sent
on a pageview. Ad click IDs, title, canonical URL, color scheme, and
arbitrary custom properties (meta tag properties included) remain
excluded. Clicks are not captured in cookieless mode.
auto_properties only adds properties to these automatic events. It cannot
drop or rename an event. beforeSend runs afterward, sees every finished
event, and can change it 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,
even when the autocapture init option is false or its URL/selector
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, plus
$rageclick, $dead_click, and $outbound_click when they apply.
- The clicked control (the semantic target) 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,.fhq-no-capture,urlBlocklist, andselectorBlockliststill win.capture_rageclicks,capture_dead_clicks,capture_outbound_clicks, andcaptureElementTextstill apply.data-fhq-prop-*andauto_propertiesapply as to any other click.- Consent and opt-out still apply; cookieless mode captures no clicks.
- The container overrides only the local
autocaptureinit option. A remote configautocapture: falseis final: island clicks stop too, and a late one removes queued click events, island clicks included. A late config that leaves the switch alone keeps queued island clicks, also ones restored from an earlier page load. mode: "pageviews"captures pageviews only, with or without the attribute. A package that wants island clicks needs an events-mode client.
With autocapture: false, the SDK still installs its click listener in
events mode. A click outside every container returns after one closest
lookup; with a remote autocapture: false every click returns at once. The submit, scroll and mutation listeners used for dead clicks
start only at the first click inside a container. A click is dead when,
within one second, nothing answered it: no DOM change, navigation, submit,
focus or state change, and no scroll anywhere on the page.
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 share the same storage keys.
When init succeeds in a browser, the client registers itself on
globalThis under Symbol.for("@founderhq/events/clients"), in a Map
keyed by the trimmed write key. Because the key is Symbol.for on
globalThis, a site and a package that bundle different copies or
versions of the SDK share one registry.
import { getFounderHqClient } from "@founderhq/events";
getFounderHqClient("fhq_pk_client_key")?.capture("newsletter.subscribed");getFounderHqClient(writeKey) returns the initialized client for that key,
or null. A package that adds analytics to part of a site (a blog, a
docs section) does not own the site's SDK setup, so it must send through
the site's client instead of starting its own.
- The return type is
FounderHqSharedClient:capture,page, andregister. The client can come from another SDK version, so the contract keeps only methods every version has. - Look the client up when you send, not once at module load. The site may
call
initlater than the package's code runs; in React, a child's effects run before its parent's. registeradds a property to every later event on the whole site. Describe a page withfhq-prop-*meta tags and a part of a page withdata-fhq-prop-*instead.- The first client that calls
initwith a key keeps the entry, unless it is a fallback client (see below). A second client with the same key keeps today's behavior (it starts and captures) and logs one warning that points togetFounderHqClient. destroy()andclose()remove the client, only if the entry still points at it.- Server rendering registers nothing;
getFounderHqClientreturnsnull.
A package that must start its own client because the site has none yet
passes yield_to_site_client: true to init. When the site's client later
calls init with the same trimmed write key, the fallback client stops at
once (its listeners are removed), takes its queued events out of the shared
storage and sends them itself (best effort), and hands the registry entry
to the site's client with no warning. Both clients use the same storage
keys, so the site's client continues the same anonymous id, session, and
consent; the yielded client writes no storage after the handover. The
handover also passes the fallback's current page (URL, $pageview_id,
start time, and properties; a waiting pageview is sent first). When the
site client's first automatic pageview would be for the same URL (and the
same cookieless or normal mode), it continues that page view (its id and
page clock) instead of sending a second one, so the landing page is
counted once and later pages only by the site's client. If a client with the key is already
running, a fallback client's init does not start it and logs nothing
(a later init may start it). The handover is a function stored on the
client under Symbol.for("@founderhq/events/yield-to-site-client"), so it
works across SDK copies and versions.
getFounderHqClient(writeKey, { includeFallback: false }) ignores a
fallback client, so a caller can tell whether the site runs its own client.
The default (includeFallback: true) returns whichever client is
registered.
There is no runtime API to add auto_properties to a client that is
already running. A provider added from a component effect runs after the
SDK's pageview microtask, so it would miss the pageview it describes; a
<meta> tag rendered in the same commit does not.
Web push
registerWebPush subscribes the browser to standard Web Push and registers
it for the identified person, so FounderHQ Sequences can send it a
notification.
founderhq.identify("user_42");
const { status } = await founderhq.registerWebPush({
applicationServerKey: "YOUR_PUBLIC_KEY", // from Sequences settings
});Your site does two things. It serves a one-line founderhq-push-sw.js from
its own origin: importScripts("https://i.getfounderhq.com/founderhq-push-sw.js");. It calls requestWebPushPermission from a click on its own button:
that shows the browser's prompt and then registers the browser.
registerWebPush never shows the prompt and does nothing until the
permission is granted, so it is safe on every page load.
A site with its own service worker keeps it. With no serviceWorkerPath,
the SDK looks for a worker from another script on the scope it would use.
If there is one, it registers nothing and returns
{ status: "failed", reason: "service_worker_exists" }. If that worker
holds the subscription the SDK stored (the site put its own worker where the
FounderHQ worker was), no worker shows a FounderHQ push any more: the SDK
ends that subscription, sends the removal for the identified person, and
forgets it. Such a site adds
importScripts("https://i.getfounderhq.com/founderhq-push-sw.js") as the FIRST line of its worker and
passes serviceWorkerPath: "/its-own-sw.js". The line must be first: the
FounderHQ listeners then run before the site's own, and they stop a
FounderHQ push and notificationclick there
(stopImmediatePropagation). A site that registers its worker itself (a
module worker, a framework) passes serviceWorkerRegistration, and the SDK
registers nothing.
| Method | Returns | What it does |
| --- | --- | --- |
| registerWebPush({ applicationServerKey, serviceWorkerPath?, serviceWorkerScope?, serviceWorkerRegistration? }) | Promise<{ status, reason? }> | Registers the worker, reuses a subscription made with the same key (or subscribes again), and stores it |
| requestWebPushPermission(options) | Promise<{ status, reason? }> | Shows the browser's permission prompt when the permission is not decided, then does what registerWebPush does. Call it from a click. In cookieless mode or without consent it does not ask. Never rejects |
| setPushEnabled(enabled) | — | The site's own switch. false stops pushes; the browser stays registered |
| dismissPushNotification(key) | Promise<number> | Closes the notifications your FounderHQ pushes showed with that notification key (or message ID), in this browser. Resolves with how many it closed; never rejects. |
| unregisterPushToken() | Promise<void> | Removes the browser from the person, forgets the subscription, and calls subscription.unsubscribe() on it. Runs after a registerWebPush that is in progress. Never rejects |
status is one of:
registered: subscribed, and sent for the identified person.not_identified: subscribed and stored. Sent on the nextidentify.permission_not_granted:Notification.permissionis notgranted.unsupported: no service worker,PushManager, orNotification.consent_required: cookieless mode, or consent is not granted. Nothing is stored or sent.failed:reasonisinvalid_application_server_key,service_worker_register_failed(the worker did not register, or did not become active in 10 seconds),subscribe_failed(the browser refused the subscription),invalid_push_endpoint,service_worker_exists,other_push_subscription, ornot_initialized.
The promise never rejects.
Rules, the same as in the mobile SDKs:
- After
initand with consent, the SDK always stores the subscription and the switch. It sends$push_device_registeredonly betweenidentifyandreset(). AsetPushEnabledcall beforeinit, in cookieless mode, or without consent does nothing: nothing is stored without consent. - The SDK ends only a subscription of its own. When the worker has a
subscription made with another key, and it is not the stored one and not
on
founderhq-push-sw.js,registerWebPushreturnsother_push_subscriptionand that subscription stays. - A site that never calls
registerWebPushgets nonavigator.serviceWorkerread and no listener. Themessagelistener for the worker is added after aregisterWebPushthat subscribed, and on a load whose stored state has a subscription. Every read ofnavigator.serviceWorkerandNotificationis guarded: a sandboxed frame that throws on them still sends analytics. - It sends the registration one time per session for the same person and
switch, and again when the subscription, the person, or the switch
changes. A page load in a new session sends it again from the stored
subscription, also when the page does not call
registerWebPush. That send reads nothing from the browser and has no$push_permission. reset()sends$push_device_removedfor the person who signs out, keeps the subscription, and clears the switch.- A removal is kept (at most 10) until ingest accepts it, and is sent first on the next page load. The queue does not drop it for its age. Each send has the time of the sign-out. A registration of the same subscription for the same person cancels a removal that still waits.
- A push device event is not activity: it does not start or extend a
session, and it skips
beforeSendand registered properties. - The endpoint must be
https:onfcm.googleapis.com,updates.push.services.mozilla.com,*.push.apple.com, or*.notify.windows.com, with no empty host label. The SDK does not store or send another endpoint, ends that subscription in the browser, and never logs the endpoint or the keys. - When consent is refused later (
consent("denied")), the SDK clears its storage and cannot send a removal. Before the clear it callssubscription.unsubscribe()on its own subscription. The push service then refuses the next push (404 or 410), and the server marks the device dead. optOut()is an analytics opt-out, not a refusal by the visitor: the SDK stops sending and clears its storage, and the browser subscription stays. CallunregisterPushToken()first to stop pushes too.- Without consent,
unregisterPushToken()sends nothing. The SDK has no stored endpoint then, so it ends only a subscription that is its own: one whose worker isfounderhq-push-sw.js, or one whose key is theapplicationServerKeythe page gave toregisterWebPushon this page load (Safari does not tell the key: there, one on theserviceWorkerPathorserviceWorkerRegistrationthe page gave). A subscription of another push product stays. - Safari does not tell the key of a subscription. With no stored key, the
SDK calls
subscribe()with its key: the browser returns the same subscription for the same key. It subscribes again only when the browser answersInvalidStateError.
On start, the SDK reads fhq_push=<message id> from the page URL (the
service worker adds it to the link of a notification), sends
$push_notification_opened with $push_message_id, and removes the
parameter with history.replaceState. This is not counted as a navigation.
The worker adds the parameter only to a link on the site's own origin. In a
Next.js App Router site the SDK passes null as the state, so the router
takes the clean URL. When a router still writes its stale URL with
fhq_push back, the SDK removes the parameter one more time.
Options for automatic properties
| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| auto_properties | (context: FounderHqAutoPropertiesContext) => Record<string, unknown> \| null \| undefined | none | Adds properties to pageviews and click-family events |
| autocapture | boolean \| FounderHqAutocaptureOptions | true | Click capture everywhere; a data-fhq-autocapture container captures clicks even when this init option is false (a remote-config false stops them too) |
| captureContext | boolean | true | Set false to skip browser context, document metadata, auto_properties, data-fhq-prop-*, and fhq-prop-* meta tags |
| yield_to_site_client | boolean | false | For a package's fallback client: hands over to the site's client when it starts with the same key; does not start if a client with the key is running |
| beforeSend | (event) => event \| null \| undefined | none | Changes or drops each finished event after automatic properties are added |
The browser SDK executes every applicable shared fixture action exactly in
@founderhq/events-core; injected now, uuid, sessionUuid, storage, and
transport options make conformance requests byte-deterministic. Tests compare
all captured requests and normalized persisted/directive outcomes to the
declared golden values.
Use @founderhq/events-react-native (or its /expo export),
FounderHQEvents for Swift, and com.founderhq:events for Kotlin on mobile.
Use @founderhq/events-node with a secret key for server-side capture.
Release notes
1.5.1
- The push worker is registered with
updateViaCache: "none", so a browser fetches new worker code on its next update check and does not take it from its cache.
1.5.0
- New:
dismissPushNotification(key)closes the notifications your FounderHQ pushes showed with that notification key (or message ID), in this browser. Call it when the person has seen what the notification is about. - The push worker (
founderhq-push-sw.js) names a notification with the push's notification key, so a later push with the same key replaces it. - Requires
@founderhq/events-core1.3.0.
1.4.0
- A visitor now starts with consent
granted. Before, a site that stated noconsent_defaultstartedpending: it recorded cookieless page views only, and ignoredidentify()and custom events untilconsent("granted")ran. consent_defaultaccepts"pending". If your site shows a consent banner and relied on the old start, passconsent_default: "pending"to keep recording nothing but cookieless page views until the visitor accepts.- A visitor's remembered answer,
opt_out_by_default, andrespect_dntwork as before.
1.3.0
- Web push:
registerWebPush,setPushEnabled, andunregisterPushToken, with the service worker filefounderhq-push-sw.js. See "Web push". - The SDK reads
fhq_pushfrom the page URL on start, sends$push_notification_opened, and removes the parameter. - A site that does not use web push gets no service worker read and no new listener.
1.2.2
- Documentation and code comments only; no change in behavior. The SDK
version sent with requests is now
1.2.2.
1.2.1
- A pageleave sent when the tab goes to the background no longer ends the
page. If the visitor comes back, the SDK sends a second pageleave for the
same
$pageview_idwhen they really leave, with the whole$page_duration_ms. Read the longest pageleave of each pageview.
1.2.0
<meta name="fhq-prop-<key>" content="…">tags add properties to the page's pageview (and its pageleave), betweenauto_propertiesand explicitpage()properties, under the same limits.data-fhq-autocapturecaptures clicks (with rage, dead, and outbound signals) inside a container, even withautocapture: falseor an allowlist that leaves it out. Blocklists and no-capture still win;mode: "pageviews"is unchanged.getFounderHqClient(writeKey)returns the page's initialized client for a write key, across bundled copies and versions of the SDK. A second client initialized with the same key warns once.{ includeFallback: false }ignores a fallback client.yield_to_site_clientmakes a package's fallback client hand over to the site's client when that one starts with the same key, keeping the same visitor, session, and landing page view.A
pushState/replaceState/popstatethat keeps the same URL no longer sends a pageleave and a pageview (Next.js router commits, refreshes, and revalidations were counted as page views).After a client-side navigation, the pageview waits (up to 1000 ms) for the new page's
fhq-page-pathtag or title before it reads$titleand meta tags; it keeps the navigation's timestamp.Meta tags are read from
<head>only and can be scoped to a page withfhq-page-path. A remote configautocapture: falsealso stops island clicks.Extra-property strings are cut to 200 characters without splitting a surrogate pair.
A scroll anywhere on the page now answers a click, so a table-of-contents jump is no longer reported as a dead click.
1.1.0
- Pageviews include the document title, language, canonical URL, and preferred color scheme. Pageleaves keep the metadata captured for their page.
auto_propertiesadds properties to pageviews and click-family events.data-fhq-prop-*attributes add top-level click properties. Both sources share key, value, and count limits; explicitpage()properties win. Pageleaves retain their pageview's extras. Callback errors do not interrupt capture, and cookieless capture keeps its shared property allowlist.- Added regression coverage for all ten requested ad click IDs already supported by the shared campaign taxonomy.
1.0.1
- Every event carries
$device_type(mobile,tablet, ordesktop), read from User-Agent Client Hints where the browser offers them and from the user agent otherwise. Web visits had no device type before. - A page opened inside another app (Instagram, LinkedIn, a native app's web
view) reports
$browser: "In-app browser"instead of no browser at all. Chrome, Firefox, and Edge for iOS are named; Edge on the desktop isEdge, notChrome.
0.8.0
$os_versioncomes from User-Agent Client Hints where available; the frozen10.15.7macOS user-agent value is reported as unknown instead. Arc is detected as$browser: "Arc". A repeatidentify()for the same person sends a property update, never a second$identify.The default host is now
https://i.getfounderhq.com.app.getfounderhq.comno longer serves SDK ingest.
