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:
| 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 person: the first touch as $initial_... traits, kept forever,
and every new touch as $latest_... traits.
| Trait suffix | Value |
|---|---|
utm_*, click IDs | The URL parameters, under the same names |
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 landing page's path, 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 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
nullfor 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:
- 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. - 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 withcampaign_id,adset_idorad_id: theutm_source, or nothing without one. - A known ad platform as
utm_source, without autm_medium. - 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 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, 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:
$fbccomes from the_fbccookie when it is less than 90 days old, otherwise from the visit'sfbclidin Meta'sfb.1.<timestamp>.<fbclid>format.$fbpcomes from the_fbpcookie, 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()oruseVoidhash().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()andsignOut()share their new anonymous ID.
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
Something wrong or missing on this page? Tell us by email or on Discord.