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

Privacy and consent

What the web SDK collects and stores in the browser, how to ask for consent first, and how to erase a visitor's data.

Use this page to describe the web SDK in your privacy policy and cookie banner, to set it up for sites that need consent before analytics, and to handle a visitor's request to erase their data.

What the SDK collects

DataWhen the SDK collects itUsed for
Anonymous IDAlways. The SDK creates one per browser.Identity, analytics
User IDWhen you call identify() with your own user ID.Identity, analytics
Email address, name, and traitsWhen you pass them to identify() or set them as person attributes. identify() sends them even without analytics consent.Identity
Pages and product eventsWhile analytics consent is granted, the default. The page's origin, path, and title, the referrer's origin and path, and the events you track.Analytics
Campaign and ad clicksWhile analytics consent is granted. utm_* parameters, ad IDs, ad platforms' click IDs, and Meta's _fbc and _fbp cookies.Attribution
Device detailsWith every request: the browser's user agent, languages, and platform. With events, while consent is granted: time zone and screen and viewport size.Analytics
IP address and locationWith every event, while analytics consent is granted. Voidhash derives the country, region, city, time zone, and approximate coordinates from the address. See IP addresses.Analytics

The SDK sends data only to Voidhash, or to the proxy or tracking domain you point it at. It sets no cookies, unless you turn on crossSubdomainIdentity, and it records no part of the page's address except the path and the campaign parameters. Its own requests carry at most your site's origin as their Referer. It reads Meta's cookies only when your site already sets them. To keep pages whose address carries personal data out, see Keep private pages out of analytics.

IP addresses

Voidhash keeps the full IP address an event was sent from for 90 days after receiving it, and removes it within a day after that. The event itself keeps only the address's network, as its $ip property, for as long as the event is kept: the first three parts of an IPv4 address, such as 203.0.113.0, or the first 48 bits of an IPv6 address. Erasing a person removes both.

What the SDK stores in the browser

The SDK keeps its state in localStorage, under keys that start with vh:2: and a short hash of your publishable key:

KeyContentsWhen it is written
identity:distinct-idThe anonymous ID, or your user ID after identify().When the SDK starts, and when the identity changes.
identity:shared-id-usedThat this browser used the cross-subdomain cookie.With crossSubdomainIdentity, when it first writes the cookie or takes an ID from it.
identity:handed-out-idThe anonymous ID your app last read, with the cross-subdomain cookie on.With crossSubdomainIdentity, when your app reads the ID; removed by reset().
identity:given-up-idsUp to five anonymous IDs this browser gave up for the cross-subdomain cookie's.With crossSubdomainIdentity, when it takes the cookie's ID, until setAdDataSharing(false) or reset().
identity:outboxidentify() links not yet delivered, with the email, name, and traits you passed.From identify() until Voidhash confirms the link.
consent:analyticsThe visitor's consent choice.When you call setAnalyticsConsent(), and when a client with analyticsConsentSource: "app" starts with an answer.
feature-flags:<id>:<keys>The last evaluated feature flags.After an evaluation, unless featureFlags.persist is false.
analytics:queue:<tab>Events waiting to be sent, one entry per tab.While analytics consent is granted.
analytics:owner:<tab>Marks which open tab owns a queue entry.Only in browsers without Web Locks, once per tab, while analytics consent is granted.
analytics:sessionThe session ID and the time of its last event.While analytics consent is granted.
analytics:fb-traitsThe $fbc and $fbp values last sent.While analytics consent is granted.
analytics:latest-touchThe last campaign's values and time, for playStoreUrl().While analytics consent is granted; removed by reset().
consent:ad-data-sharing-revocationsIDs whose agreement to sharing with ad platforms is still to be taken back.From setAdDataSharing(false) until Voidhash has written it, or the first page after 48 hours while it has no such person.
network:circuit:<api|ingest>:<origin>Until when requests to a Voidhash origin pause after repeated failures.When Voidhash cannot be reached; removed once it answers again.
__keys__An index of the SDK's keys.With the keys above.

Also:

  • sessionStorage key @voidhash/web:<publishable key>:touch: a campaign the visitor arrived from while consent is pending, held for the browser session, and, once consent is granted, the last campaign recorded, so a reload is not counted again. It is removed when there is nothing to keep, and never written while consent is denied.
  • In browsers without the Web Locks API, a short-lived localStorage key starting with vh:lock: while a tab sends events.
  • When it starts, the SDK writes and immediately removes __voidhash_probe__ to check that storage is available.

Withdrawing analytics consent removes every analytics: key. With storeBeforeConsent: false, nothing but consent:analytics is stored while consent is not granted, and a revocation until Voidhash has written it, or until the first page after 48 hours while Voidhash has no such person: without it, a page closed during an outage, or before their agreement finished processing, would leave sharing with ad platforms on.

The cross-subdomain cookie

With crossSubdomainIdentity: true, off by default, the SDK sets one cookie. List it in your cookie banner if you turn it on.

Turn it on only if you trust every subdomain

Turn this on only when every subdomain of your registrable domain is yours and trusted. Each of them can read the cookie, from a script or from the requests the browser sends it, and each can set it. Voidhash's client API answers for an anonymous ID with that person's subscriptions and perks, so whoever reads the ID can read those, and a subdomain that sets the ID decides which person a visitor becomes. This includes subdomains a vendor runs, such as a help center or a status page, and any a forgotten DNS record would let someone take over.

One narrower limit remains on top of that. A subdomain learns from this cookie that a sign-in elsewhere linked its ID to a user. If the cookie is gone before the visitor returns to that subdomain, because Safari's cap ended it or consent was withdrawn, the subdomain keeps the ID, and the next visitor on that browser, on that subdomain, can still read that user's subscriptions and perks. It takes all of those: one browser shared by two people, a sign-in on one subdomain, and no visit to the other while the cookie lasted. Calling reset() on that subdomain replaces the ID.

NameContentsAttributes
vh_id_<hash of your key>An anonymous ID, and short digests of up to three IDs a sign-in has linked to a user. Never a user ID or an email.Your registrable domain, Path=/, SameSite=Lax, Secure on https, up to 400 days.
  • It is read and written only while analytics consent is granted. While consent is pending or denied, the SDK does not touch it. The one exception is the next point, while a consent tool that answered granted on an earlier visit has not answered yet on this page.
  • When a visitor is identified, the SDK rewrites it with a fresh anonymous ID and a digest of the ID the visitor had, because that ID now leads to the user's subscriptions and perks. It keeps the last three digests. A signed-in device never takes an ID from the cookie.
  • A device that finds a digest of its own ID there gives that ID up. It takes the cookie's ID, and forgets the campaign it recorded and any it holds for consent; what it recorded went to Voidhash and stays with the user. This comes before the campaign rule below, and it happens when a page starts, when an open page follows an identity change, and when a page returns from the back/forward cache. A list that is not well formed is ignored.
  • Withdrawing consent deletes it, but not for good. Consent is kept per subdomain, so another subdomain with a stored grant can set the cookie again, when it signs a visitor in or out or has not used the cookie yet. Pass the same answer to every subdomain, for example from one consent tool on all of them.
  • Otherwise, a visitor keeps their ID when their device has a recorded campaign, or when your app has read the ID with getDistinctId() or useVoidhash().distinctId, as it may be on a payment that an anonymous ID cannot hand on to another. They leave another ID in the cookie alone. A device with neither takes the cookie's ID.
  • 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. Another subdomain may have linked the ID to a user in the meantime, and writing it back would hand that user's subscriptions and perks to the next visitor on the browser. So it does not come back after the cookie ran out or consent was withdrawn.
  • As with any cookie on your domain, the browser also sends it to your own servers on every subdomain. Nothing of the SDK reads it there.
  • The SDK finds your registrable domain with a second cookie, vh_id_<hash>_probe, set and removed at once on each candidate from the top domain down, as browsers refuse public suffixes such as co.uk.
  • A value that is not an anonymous ID is ignored and replaced.

Safari limits cookies set from JavaScript. It caps them at 7 days, and at 24 hours on a page reached through a link with a click ID from a domain it classifies as a tracker, which covers many ad landing pages. A visitor who returns after that keeps the ID each subdomain has in its storage, and a subdomain they had not visited yet starts a new anonymous visitor. Signed-in users are unaffected: identify() links them on every subdomain.

If you turn the option off later, the SDK stops reading the cookie, and it stays until it expires.

Ask for consent first

Many consent regimes, such as the EU's, require consent before a site stores an identifier on the device or records analytics. For those sites, start the client with consent pending, keep its state off the device until the visitor agrees, and pass every answer from your consent tool to the SDK:

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

export const voidhash = createVoidhashClient({
  analyticsConsent: "pending",
  publishableKey: "vh_pk_…",
  storeBeforeConsent: false,
});
void voidhash.initialize();

// Call this when the visitor answers your consent banner.
export function onConsentChange(analyticsAllowed: boolean) {
  void voidhash.setAnalyticsConsent(analyticsAllowed ? "granted" : "denied");
}

Until the visitor answers:

  • No page views, page leaves, outbound clicks, events, sessions, or person attributes are recorded, stored, or sent.
  • Nothing is written to localStorage or sessionStorage. The anonymous ID, cached feature flags, and identify() links live in memory for the page, so each page load is a new anonymous visitor and flags are evaluated again on every page.
  • The SDK sends nothing, unless you evaluate feature flags, call identify(), call setPersonAttributesSync(), which then only reads the person, or call setAdDataSharing(false). Those requests tell Voidhash that consent is missing, so it records no analytics for them. identify() still sends the email, name, and traits you pass to it; leave it out until consent if your consent regime treats linking a visitor to your user as analytics.
  • The campaign the visitor arrived from is held in memory for the page, never in storage. It survives client-side navigations, and is recorded with the time of the click if they grant consent. A full page load before they answer loses it, so show the banner on your landing pages.

When the visitor answers:

  • granted. The SDK records from then on and writes the page's state to the device, so the visitor is recognized on later visits. Nothing skipped before is sent later, except the campaign. Other open tabs of your site follow the answer and take on the identity of the tab where the visitor answered, and a campaign they hold is recorded under it.
  • denied. Nothing is recorded, the campaign held in memory is dropped, and the choice is the only thing the SDK stores. A returning visitor who declined starts with only that key on the device.

Withdrawing consent later discards every queued event, including a batch that is being sent, in every open tab, removes the SDK's state from the device again, and deletes the cross-subdomain cookie; the page keeps using its state in memory.

The SDK stores the choice, and a stored choice wins over the analyticsConsent option on later visits, so a banner of your own asks only once. A consent tool keeps the answer itself: make it the only source instead, as in Use a consent tool.

In React, useAnalytics() exposes consent and setAnalyticsConsent, and re-renders when consent changes in any tab:

const { consent, setAnalyticsConsent } = useAnalytics();

if (consent !== "pending") return null;
return (
  <ConsentBanner
    onAccept={() => void setAnalyticsConsent("granted")}
    onDecline={() => void setAnalyticsConsent("denied")}
  />
);

With the default storeBeforeConsent: true, the SDK keeps its anonymous ID, cached flags, pending identify() links, and, while consent is pending, the campaign in the browser before consent, as listed above. The visitor then stays one visitor across page loads before they answer, and a campaign seen before consent is sent if consent is granted later in the same browser session.

See Analytics consent for what consent controls in analytics.

When someone rejects everything

Analytics consent and the agreement to share purchases with ad platforms are separate, so declining analytics does not take the agreement back by itself. Your consent tool must call both:

void voidhash.setAnalyticsConsent("denied");
void voidhash.setAdDataSharing(false);

setAdDataSharing(false) works whatever analytics consent says, for anonymous visitors and signed-in users alike. It is stored on the device and retried until Voidhash has written it, also from a later page, so a failed request does not leave sharing on. Voidhash writes only that one trait, on a person it already has: it creates no person for someone who declined, and records no analytics.

With crossSubdomainIdentity, it also reaches the anonymous IDs this browser gave up for the cookie's, up to the last five, as purchases under them keep renewing. It does not reach an ID a sign-in retired, which may be someone else's on a shared browser, and reset() and signOut() forget the list, as the next person is someone else.

When Voidhash has no such person, their agreement may still be processing, so the SDK asks again with growing waits, also from a later page, and stops after 48 hours. That is up to about 15 requests. The SDK only runs on your pages, so the ID stays on the device until the first page it loads after those 48 hours, which removes it: a visitor who never comes back keeps it. Agreeing again with setAdDataSharing(true) needs analytics consent, and removes a revocation still waiting.

Call it when the visitor answers, not on every page load. A browser keeps up to 20 waiting revocations, and past that it drops one Voidhash has no person for first, and reports an AD_DATA_SHARING_REVOCATION_DROPPED diagnostic, as it does when one runs out its 48 hours.

Use a consent tool

A consent tool such as Cookiebot or OneTrust keeps the visitor's answer itself, and its script often loads after yours. Make it the only source of the answer, so the SDK never acts on an answer it stored on an earlier visit:

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

export const voidhash = createVoidhashClient({
  analyticsConsentSource: "app",
  publishableKey: "vh_pk_…",
  storeBeforeConsent: false,
});
void voidhash.initialize();

Then pass every answer the tool reports to setAnalyticsConsent(): the stored answer it loads on each page, and every change the visitor makes. With analyticsConsentSource: "app":

  • Every page starts from the analyticsConsent option, pending by default, whatever an earlier visit stored. A visitor who withdrew consent in the tool while your site was closed is not recorded under their earlier answer.
  • pending means the tool has not answered yet. The page records and sends nothing, and discards nothing either: the events earlier pages queued, and what the visitor's last answer let the SDK store, stay on the device until the answer arrives, unless another open tab of your site, which has a granted answer, sends them first. granted records the page the visitor is on, once, and sends what waited; denied discards it, as withdrawing consent always does.
  • The SDK passes each answer to your site's other open tabs, as it does without the option.

If you already know the answer when you create the client, for example from the tool's cookie, pass it as analyticsConsent.

If you share purchases with ad platforms, also call setAdDataSharing(false) when the tool reports a rejection. See When someone rejects everything.

Cookiebot's statistics category covers analytics. Cookiebot fires CookiebotOnConsentReady once it knows the answer, stored or new, and CookiebotOnAccept or CookiebotOnDecline when the visitor answers:

declare global {
  interface Window {
    Cookiebot?: { consent: { statistics: boolean }; hasResponse: boolean };
  }
}

function applyCookiebotConsent() {
  const cookiebot = window.Cookiebot;
  if (!cookiebot) return;
  // Before the visitor answers, `statistics` is false too: that is `pending`, not `denied`.
  void voidhash.setAnalyticsConsent(
    cookiebot.consent.statistics ? "granted" : cookiebot.hasResponse ? "denied" : "pending",
  );
}

for (const event of ["CookiebotOnConsentReady", "CookiebotOnAccept", "CookiebotOnDecline"]) {
  window.addEventListener(event, applyCookiebotConsent);
}
// Cookiebot may have loaded the answer before this code ran.
applyCookiebotConsent();

OneTrust usually puts analytics in its Performance category, C0002; check the ID under Categorization → Categories in OneTrust. OneTrust fires OneTrustGroupsUpdated with the active categories when it loads and whenever the visitor changes them:

declare global {
  interface Window {
    OneTrust?: { IsAlertBoxClosed: () => boolean };
    OnetrustActiveGroups?: string;
  }
}

const ANALYTICS_CATEGORY = "C0002";

function applyOneTrustConsent(activeGroups: string | ReadonlyArray<string>) {
  // Until the visitor answers, or after their answer expired, the banner is open: `pending`.
  void voidhash.setAnalyticsConsent(
    activeGroups.includes(ANALYTICS_CATEGORY)
      ? "granted"
      : window.OneTrust?.IsAlertBoxClosed()
        ? "denied"
        : "pending",
  );
}

window.addEventListener("OneTrustGroupsUpdated", (event) => {
  if (event instanceof CustomEvent) applyOneTrustConsent(event.detail);
});
// OneTrust may have loaded before this code ran.
if (window.OnetrustActiveGroups) applyOneTrustConsent(window.OnetrustActiveGroups);

Keep private pages out of analytics

Some addresses carry data that must not reach Voidhash: a health result in /app/results/waist, a customer ID in an admin page, a token in an invitation link. analytics.sanitizeUrl sees each address before the SDK records it, and returns the address to record or null to keep it out:

const voidhash = createVoidhashClient({
  analytics: {
    sanitizeUrl: (url) => {
      // Pages with health data stay out entirely.
      if (url.pathname.startsWith("/app/")) return null;
      // Admin and invitation pages are recorded, without the customer ID or the token.
      url.pathname = url.pathname
        .replace(/^\/admin\/customers\/[^/]+/, "/admin/customers/:id")
        .replace(/^\/invite\/[^/]+/, "/invite/:token");
      return url;
    },
  },
  publishableKey: "vh_pk_…",
});

On a page it returns null for:

  • No page view is recorded, on load or after a navigation, and no page leave, outbound click, or campaign touch. The page left for it is still recorded, under its own address.
  • Events recorded there carry no address and no title. To record nothing at all on those pages, also drop their events in beforeSend, or pause recording before your app opens them.
  • The SDK keeps running: feature flags, identify(), and sending what other pages queued.

It also runs for the referrer, which can be another site's address; a referrer it returns null for is not recorded. An address it changes is recorded instead of the real one everywhere, including a campaign touch's landing path, and the campaign is read from its query, so change the URL you get rather than build a new one. It may also return a string, read relative to the page's address. A function that throws keeps the address out, and the SDK reports a SANITIZE_URL_FAILED diagnostic.

sanitizeUrl covers the page's address and the title of a page it keeps out, not the data you pass yourself. Keep personal data out of track() and setPersonAttributes(), and give pages you keep a title without it, or remove those values in beforeSend.

In the Next.js App Router, pass these function options to VoidhashProvider from a "use client" module of your own.

Location behind a proxy

Voidhash derives a visitor's location from the address its request comes from. Behind a first-party proxy, that is the proxy's address, so events sent through a proxy carry the proxy's location. A tracking domain keeps each visitor's own location.

Erase a visitor's data

When a visitor withdraws consent, call setAnalyticsConsent("denied"). The SDK stops recording at once and discards what it has queued.

When a user asks you to erase their data:

  1. Request an erasure from your backend or in Studio. It removes the person, their events with the IP addresses stored for them, and the anonymous profiles merged into them from Voidhash.
  2. In their browser, call reset(), so later events no longer carry their ID. Events sent under the erased ID after the erasure would start a new person.
  3. To remove everything the SDK keeps in the browser as well, destroy the client and delete its keys. This also removes the consent choice, so the visitor is asked again. Setting consent to denied first deletes the cross-subdomain cookie, if you use it, also before initialize(). Another subdomain with a stored grant can set it again, so do this on each one.
await voidhash.setAnalyticsConsent("denied");
await voidhash.destroy();
for (const storage of [localStorage, sessionStorage]) {
  for (const key of Object.keys(storage)) {
    if (key.startsWith("vh:") || key.startsWith("@voidhash/web:")) storage.removeItem(key);
  }
}

A new client created after that starts as a new anonymous visitor.

Coming from posthog-js

The web SDK's privacy options and their posthog-js counterparts:

posthog-jsVoidhash web SDK
before_sendanalytics.beforeSend. The page's address is in the event's context, not in $current_url.
sanitize_properties, property_denylistanalytics.beforeSend
Rewriting $current_url, $pathname, or $referrer in before_sendanalytics.sanitizeUrl, which also covers the landing path of a campaign
opt_in_capturing(), opt_out_capturing()setAnalyticsConsent("granted"), setAnalyticsConsent("denied")
has_opted_out_capturing()getAnalyticsConsent() !== "granted"
opt_out_capturing_by_default: trueanalyticsConsent: "pending"
Calling opt_in_capturing() and opt_out_capturing() from a consent toolanalyticsConsentSource: "app", and setAnalyticsConsent() from the tool
opt_out_persistence_by_default: truestoreBeforeConsent: false
cookieless_mode: "on_reject"storeBeforeConsent: false. A visitor who declines is not recorded at all.
cross_subdomain_cookie: truecrossSubdomainIdentity: true. Off by default, only while consent is granted, and only ever an anonymous ID.
capture_pageview: "history_change"The default
capture_pageview: falseanalytics: { capturePageViews: false }
NonepauseAnalytics() and resumeAnalytics(), which stop recording without changing consent or dropping the queue
PreviousTrack eventsNextPage views and attribution

On this page

What the SDK collectsIP addressesWhat the SDK stores in the browserThe cross-subdomain cookieAsk for consent firstWhen someone rejects everythingUse a consent toolKeep private pages out of analyticsLocation behind a proxyErase a visitor's dataComing from posthog-js