Page views and attribution
Record page views and sessions, and attribute visitors and revenue to campaigns and ads.
The web SDK records which pages visitors see and which campaign or ad brought them to your site. Combined with your Stripe payments, this tells you the trials, conversions, revenue and refunds each campaign, ad set and ad produced.
Page views
The SDK records a $pageview event when the page loads and after every client-side navigation
that changes the path, through history.pushState, history.replaceState or the back and forward
buttons. Changes to the hash do not count, and a navigation that only changes the query string is
not a new page view.
This covers single-page apps without any router code: Next.js (App Router and Pages Router), React
Router, TanStack Router, Vue Router, SvelteKit, and any router built on the History API. A router
that keeps the route in the hash, such as Vue Router's hash mode or React Router's
createHashRouter, records only the first page view, and the hash is not recorded; call
page() with the route's name on its route changes.
Every event carries the page's origin, path and title, the referrer's origin and path, and the screen and viewport size. The query string is not recorded, except for the campaign parameters below.
To record page views yourself, turn the automatic ones off and call page():
const voidhash = createVoidhashClient({
analytics: { capturePageViews: false },
publishableKey: "vh_pk_…",
});
await voidhash.page("Pricing", { experiment: "annual-first" });Campaign attribution is recorded either way.
page() sends the same $pageview event the SDK records automatically. If your app calls page()
on every route change, set capturePageViews: false, or every navigation is counted twice.
The SDK also records a $pageleave as each page view ends, with its time on the page, scroll depth,
and Web Vitals, unless you turn it off. See Page engagement.
$pageview and $pageleave are built-in events that self-hosted installs do not store by
default. Turn them on in the project's event settings, or they are discarded when they arrive.
Create one client per project on a page. Two clients for the same project each record their own page views, so every page would be counted twice.
Keep pages out
To keep pages out of page views and out of every event's context, for example because their
address carries personal data, return null for them from analytics.sanitizeUrl. It can also
rewrite an address, for example to replace a token in an invitation link. See
Keep private pages out of analytics.
Sessions
Every event carries a session id. A session ends after 30 minutes without events, the same as on
iOS, Android and React Native. All tabs of your site share the session, and reset() starts a new
one.
Campaign attribution
A visit whose URL has any of these parameters is a campaign touch:
| Parameter | Source |
|---|---|
utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id | Any campaign |
fbclid | Meta ad clicks |
gclid, gbraid, wbraid, gad_source, gad_campaignid, dclid | Google ad clicks |
ttclid | TikTok ad clicks |
msclkid | Microsoft Ads clicks |
twclid | X ad clicks |
li_fat_id | LinkedIn ad clicks |
ScCid | Snapchat ad clicks |
rdt_cid | Reddit ad clicks |
epik | Pinterest ad clicks |
campaign_id, adset_id, ad_id | Ad ids you add to links |
The SDK writes each touch to the visitor's person profile:
- The first touch is kept forever, as the
$initial_…traits. - Every new touch replaces the
$latest_…traits.
| Trait suffix | Value |
|---|---|
utm_*, click IDs | The URL parameters, unchanged, under the same names |
ad_network | The ad network, see below |
ad_campaign_id | campaign_id, else utm_id, else gad_campaignid |
ad_adset_id | adset_id |
ad_id | ad_id |
landing_path | The path of the page the visitor landed on, without the query string |
referrer_origin | The origin of the referring page |
touch_at | When the touch happened, in ISO-8601 UTC |
For example, $initial_ad_campaign_id is the campaign of the visitor's first ad click and
$latest_utm_source the source of their most recent campaign visit. The page view of the touch
carries the same values as $utm_source, $ad_campaign_id and so on.
The SDK reads the campaign from the page's address when it initializes and after each client-side
navigation. If your app removes the campaign parameters from the address, for example to tidy the
URL, initialize the SDK first. A page that analytics.sanitizeUrl keeps out records no campaign;
for an address it rewrites, the SDK reads the campaign and the landing path from the rewritten one.
Reloading the page or navigating within your site with the same campaign parameters does not count
as a new touch. Each touch writes every trait, with null for the values it does not have, so the
first-touch and latest-touch traits always describe a single visit and never mix values from two.
The page view of a touch carries only the values it has.
While analytics consent is pending, the SDK holds the touch and sends nothing. If the visitor grants
consent, the touch is recorded once, with the time of the click, even if they moved on to another
page in the meantime. With storeBeforeConsent: false it is held in memory only, so a full page load
before they answer loses it. See Ask for consent first.
When a visitor signs in, identify() links their anonymous profile, including its campaign traits,
to the user.
Voidhash also keeps each touch's campaign values and time, so Ads reports can credit a signup to the visitor's last touch from an ad within your project's attribution window, else their last other touch. See Ad measurement.
How the ad network is decided
The first rule that applies sets ad_network:
- A click ID.
fbclidismeta;gclid,gbraid,wbraid,gad_source,gad_campaignidanddclidaregoogle;ttclidistiktok;msclkidismicrosoft;twclidisx;li_fat_idislinkedin;ScCidissnapchat;rdt_cidisreddit;epikispinterest. - A paid
utm_medium, or an ad ID. With autm_mediumofcpc,ppc,paid,paid_social,paidsocial,paid-social,paid_search,paidsearch,paid-search,cpm,cpv,display,banner,retargeting,remarketing,ad,adsorsem, or with any ofcampaign_id,adset_idandad_id, it is theutm_source, or nothing without one. - A known ad platform without a
utm_medium. Theutm_sourcealone, when it names one of the platforms below. - Otherwise nothing. The touch is organic: Voidhash keeps its campaign values, but no ad network gets the credit.
A utm_source is matched in any case, and platforms go by one name: facebook, fb, instagram,
ig and meta are meta; google, adwords and googleads are google; bing and microsoft
are microsoft; twitter and x are x; tiktok, linkedin, snapchat, reddit and
pinterest are themselves. Any other source is lowercased.
| URL parameters | ad_network |
|---|---|
utm_source=Newsletter | None, organic |
utm_source=newsletter&utm_medium=paid_social | newsletter |
utm_source=facebook&utm_medium=social | None, organic |
utm_source=IG | meta |
ttclid=…&utm_source=newsletter | tiktok |
Meta ads
Add these URL parameters to your ads. In Ads Manager, set them under Tracking > URL parameters at the ad level:
utm_source=facebook&utm_medium=paid_social&utm_campaign={{campaign.name}}&utm_term={{adset.name}}&utm_content={{ad.name}}&campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}Meta fills in the {{…}} values when the ad is clicked, and adds fbclid itself. The ids give
exact attribution to a campaign, ad set and ad even after you rename them.
If your ads already have URL parameters of their own, you can keep them as long as one of them
carries {{ad.id}}, such as utm_id={{ad.id}}. With your
Meta ad account connected, Voidhash finds the ad set and campaign of that ID
among the synced ads.
The SDK also stores Meta's click and browser ids on the person, as $fbc and $fbp:
$fbccomes from the Meta pixel's_fbccookie when it is present and less than 90 days old. Otherwise the SDK builds it from thefbclidof the visit, in Meta'sfb.1.<timestamp>.<fbclid>format.$fbpcomes from the Meta pixel's_fbpcookie, when your site uses the pixel.
The SDK reads these cookies but never sets them.
To send a person's purchases to Meta's Conversions API, Voidhash also needs that person's agreement to sharing with ad platforms, which is separate from analytics consent. Once they gave it, call:
await voidhash.setAdDataSharing(true);This sets the person trait $ad_data_sharing. Like any person attribute, it does nothing while
analytics consent is not granted. setAdDataSharing(false) takes it back, and analytics consent
neither blocks nor discards that: declining analytics does not do it for you, so when someone rejects
everything, your consent tool must
call both. In React, useAnalytics() exposes it
too. See Meta ads.
Keep one visitor across subdomains
Browsers keep localStorage per origin, so www.example.com and app.example.com see two anonymous
visitors until the visitor signs in on both. To make them one from the start, turn on
crossSubdomainIdentity in the client of every subdomain, with the same publishable key:
const voidhash = createVoidhashClient({
crossSubdomainIdentity: true,
publishableKey: "vh_pk_…",
});The SDK then keeps the anonymous ID in a first-party cookie on your registrable domain as well, while analytics consent is granted:
- An anonymous visitor takes the ID in the cookie, or shares their own when there is none yet. Granting consent does this at that moment, before anything is recorded.
- A visitor whose device has a recorded campaign keeps their ID, so the campaign stays with them, and leaves another ID in the cookie alone. Two subdomains that each have a campaign each keep their own ID, as they would with the option off, until the visitor signs in.
- So does a visitor whose ID your app has read with
getDistinctId()oruseVoidhash().distinctId, as it may be on a payment, and Voidhash cannot move a purchase between two anonymous IDs. A first page load takes the cookie's ID before your app can read it. If you ask for consent later, read the ID when you need it, such as at checkout: a read before the grant keeps the visitor on that ID afterwards. - A device whose ID a sign-in on another subdomain has linked to a user gives that ID up, whatever else applies, and takes the cookie's ID: the ID would otherwise give the next visitor on that browser access to the user's subscriptions and perks. What it recorded stays with the user.
- A device writes its own ID to the cookie only the first time, if it never used the cookie. After that it only renews the cookie while the cookie holds that ID, as another subdomain may have linked the ID to a user by then. It does not write the ID back after the cookie ran out or consent was withdrawn.
- When a visitor signs in, the cookie gets a fresh anonymous ID, as theirs now leads to their account, and a short digest of theirs, for the other subdomains. A signed-in visitor keeps their ID and takes none from the cookie, which never holds a user ID.
reset()andsignOut()share the new anonymous ID.- Withdrawing consent deletes the cookie, but another subdomain with a stored grant can set it again. Pass the same answer to every subdomain.
Turn this on only when every subdomain of your registrable domain is yours and trusted. Each of them can read the cookie and set it, and the ID in it gives access to that person's subscriptions and perks through the client API. See The cross-subdomain cookie, which also describes a narrower limit when the cookie is gone before a visitor returns.
A visitor who was already seen on two subdomains before you turn it on keeps the history of each under
its own ID: taking the cookie's ID does not merge the two. Safari limits how long the cookie lasts.
Hosts under a shared domain such as *.vercel.app cannot share a cookie.
Credit app installs to a campaign
For a link to your Android app, playStoreUrl() passes the visitor's latest campaign on to the
Android SDK through Google Play. See
Web-to-app handoff.
Attribute Stripe payments
To credit revenue, renewals, and refunds to the campaign that brought the visitor, pass the visitor's Voidhash ID to Stripe when your server creates the Checkout Session or customer. See Stripe revenue.
Bots and crawlers
Events from well-known crawlers, link-preview fetchers, uptime monitors and headless browsers are discarded when they arrive, so they do not count as page views, sessions or campaign touches.