VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
Web SDK
Web quickstart
Script tag
Identify users
Evaluate feature flags
Track
Track events
Page views and attribution
Privacy
Privacy and consent
Browser storage
Connect a consent tool
Keep data out of analytics
Revenue and attribution
Sell on the web
Stripe revenue
Web-to-app handoff
Delivery
First-party proxy
Tracking domain
Reference
Web SDK reference
Errors
Troubleshooting

Page views and attribution

Record page views, and attribute visitors and their revenue to campaigns and ads.

The web SDK records which pages visitors see and which campaign or ad brought them. Combined with Stripe purchases, this tells you the trials, revenue and refunds each campaign, ad set and ad produced. Ad attribution in your app covers the other SDKs and Meta's Conversions API; Ad measurement explains attribution windows and the Ads reports.

Page views

The SDK records a $pageview on load and after every client-side navigation that changes the path: history.pushState, history.replaceState and the back and forward buttons. Hash changes and query-only changes are not new page views. This covers Next.js, React Router, TanStack Router, Vue Router, SvelteKit and any other History API router without extra code.

A hash router, such as Vue Router's hash mode or React Router's createHashRouter, records only the first page view. Call page() with the route's name on its route changes.

The query string is never recorded, except for the campaign parameters below. To keep pages out of analytics by address, see Keep data out of analytics.

To record page views yourself, turn the automatic ones off. Otherwise every navigation is counted twice.

import { createVoidhashClient } from "@voidhash/web";

const voidhash = createVoidhashClient({
  analytics: { capturePageViews: false },
  publishableKey: "vh_pk_...",
});

await voidhash.page("Pricing", { experiment: "annual-first" });

Campaign attribution is recorded either way. Each page view also ends with a $pageleave; see Page engagement. Events from well-known crawlers, link-preview fetchers, uptime monitors and headless browsers are discarded on arrival.

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 person: the first touch as $initial_... traits, kept forever, and every new touch as $latest_... traits.

Trait suffixValue
utm_*, click IDsThe URL parameters, under the same names
ad_networkSee below
ad_campaign_idcampaign_id, else utm_id, else gad_campaignid
ad_adset_idadset_id
ad_idad_id
landing_pathThe landing page's path, 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 first ad click. The touch's page view carries the same values, as $utm_source, $ad_campaign_id and so on.

  • The SDK reads the campaign on initialize and after each navigation. If your app strips campaign parameters from the URL, initialize the SDK first.
  • Reloading or navigating with the same parameters is not a new touch. Each touch writes every trait, with null for missing values, so traits never mix two visits.
  • While consent is pending, the touch is held and recorded once, with the time of the click, if consent is granted. See Ask for consent first.
  • identify() carries the anonymous visitor's campaign traits over to the user.

How the ad network is decided

The first rule that applies sets ad_network:

  1. A click ID. fbclid → meta; gclid, gbraid, wbraid, gad_source, gad_campaignid, dclid → google; ttclid → tiktok; msclkid → microsoft; twclid → x; li_fat_id → linkedin; ScCid → snapchat; rdt_cid → reddit; epik → 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 campaign_id, adset_id or ad_id: the utm_source, or nothing without one.
  3. A known ad platform as utm_source, without a utm_medium.
  4. Otherwise nothing. The touch is organic: its values are kept, but no ad network gets credit.

utm_source is matched in any case and normalized: facebook, fb, instagram, ig, meta → meta; google, adwords, googleads → google; bing, microsoft → microsoft; twitter, x → x; tiktok, linkedin, snapchat, reddit, pinterest stay as they are. 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, 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 and adds fbclid itself. The IDs keep attribution exact after you rename a campaign. If your ads already have their own parameters, one carrying {{ad.id}}, such as utm_id={{ad.id}}, is enough once your Meta ad account is connected.

The SDK also stores Meta's click and browser IDs on the person. It reads the pixel's cookies but never sets them:

  • $fbc comes from the _fbc cookie when it is less than 90 days old, otherwise from the visit's fbclid in Meta's fb.1.<timestamp>.<fbclid> format.
  • $fbp comes from the _fbp cookie, when your site uses the Meta pixel.

To send a person's purchases to Meta's Conversions API, Voidhash needs their agreement to sharing with ad platforms, which is separate from analytics consent:

await voidhash.setAdDataSharing(true);

This sets the person trait $ad_data_sharing and needs analytics consent. To take it back, call setAdDataSharing(false); see When someone rejects everything and Share purchases with Meta.

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 on every subdomain, with the same publishable key:

import { createVoidhashClient } from "@voidhash/web";

const voidhash = createVoidhashClient({
  crossSubdomainIdentity: true,
  publishableKey: "vh_pk_...",
});

The SDK then also keeps the anonymous ID in a first-party cookie on your registrable domain, while analytics consent is granted:

  • An anonymous visitor takes the cookie's ID, or shares their own when there is none.
  • A visitor keeps their own ID, and leaves the cookie alone, when their device has a recorded campaign, or when your app read the ID with getDistinctId() or useVoidhash().distinctId, as it may already be on a payment. Read the ID when you need it, such as at checkout.
  • When a visitor signs in, the cookie gets a fresh anonymous ID and a digest of the old one. A device on another subdomain that holds the old ID gives it up and takes the new one, so the next person on that browser cannot reach the user's subscriptions. A signed-in device never takes an ID from the cookie.
  • A device writes its own ID to the cookie only if it never used the cookie before.
  • reset() and signOut() share their new anonymous ID.
Only for subdomains you trust

Every subdomain can read and set the cookie, and its ID gives access to that person's subscriptions and perks. See The cross-subdomain cookie.

Visitors seen on two subdomains before you turn it on keep each history under its own ID. Hosts under a public suffix, such as *.vercel.app, cannot share a cookie.

Next steps

  • Attribute Stripe revenue
  • Credit Android installs to a campaign
  • Ads reports

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousTrack eventsNextPrivacy and consent

On this page

Page viewsCampaign attributionHow the ad network is decidedMeta adsKeep one visitor across subdomainsNext steps