VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Environments
Products and perks
Paywall locations
CLI
Paywall components
Components
Develop a component
Editor panels
Component definition
Publishing
Installing and capabilities
SDKs
Swift
Kotlin
React Native
Web
Store setup
Connect the App Store
Connect Google Play
Connect Stripe
Record payments from other sources
API libraries
Node.js
PHP
Rust
Go
Resources
MCP server
Ad measurement
Meta ads
Migrate in observer mode
Delete a person's data
Web SDK
Web quickstart
Script tag
Identify users
Track events
Privacy and consent
Page views and attribution
Sell on the web
Stripe revenue
Web-to-app handoff
First-party proxy
Tracking domain
Feature flags
Troubleshooting

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.

Self-hosted projects

$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:

ParameterSource
utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_idAny campaign
fbclidMeta ad clicks
gclid, gbraid, wbraid, gad_source, gad_campaignid, dclidGoogle ad clicks
ttclidTikTok ad clicks
msclkidMicrosoft Ads clicks
twclidX ad clicks
li_fat_idLinkedIn ad clicks
ScCidSnapchat ad clicks
rdt_cidReddit ad clicks
epikPinterest ad clicks
campaign_id, adset_id, ad_idAd 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 suffixValue
utm_*, click IDsThe URL parameters, unchanged, under the same names
ad_networkThe ad network, see below
ad_campaign_idcampaign_id, else utm_id, else gad_campaignid
ad_adset_idadset_id
ad_idad_id
landing_pathThe path of the page the visitor landed on, without the query string
referrer_originThe origin of the referring page
touch_atWhen 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:

  1. A click ID. fbclid is meta; gclid, gbraid, wbraid, gad_source, gad_campaignid and dclid are google; ttclid is tiktok; msclkid is microsoft; twclid is x; li_fat_id is linkedin; ScCid is snapchat; rdt_cid is reddit; epik is pinterest.
  2. A paid utm_medium, or an ad ID. With a utm_medium of cpc, ppc, paid, paid_social, paidsocial, paid-social, paid_search, paidsearch, paid-search, cpm, cpv, display, banner, retargeting, remarketing, ad, ads or sem, or with any of campaign_id, adset_id and ad_id, it is the utm_source, or nothing without one.
  3. A known ad platform without a utm_medium. The utm_source alone, when it names one of the platforms below.
  4. 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 parametersad_network
utm_source=NewsletterNone, organic
utm_source=newsletter&utm_medium=paid_socialnewsletter
utm_source=facebook&utm_medium=socialNone, organic
utm_source=IGmeta
ttclid=…&utm_source=newslettertiktok

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:

  • $fbc comes from the Meta pixel's _fbc cookie when it is present and less than 90 days old. Otherwise the SDK builds it from the fbclid of the visit, in Meta's fb.1.<timestamp>.<fbclid> format.
  • $fbp comes from the Meta pixel's _fbp cookie, 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() or useVoidhash().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() and signOut() 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.

PreviousPrivacy and consentNextSell on the web

On this page

Page viewsKeep pages outSessionsCampaign attributionHow the ad network is decidedMeta adsKeep one visitor across subdomainsCredit app installs to a campaignAttribute Stripe paymentsBots and crawlers