VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
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
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
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, 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 never sets cookies, and it records no part of the page's address except the path and the campaign parameters. It reads Meta's cookies only when your site already sets them.

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: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().
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.
__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.

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 from your consent tool when the visitor answers, and when it loads a stored answer.
export function onConsentChange(analyticsAllowed: boolean) {
  void voidhash.setAnalyticsConsent(analyticsAllowed ? "granted" : "denied");
}

Until the visitor answers:

  • No page views, 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(), or call setPersonAttributesSync(), which then only reads the person. 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 not held anywhere. When they grant consent, the SDK reads it from the address the page is on at that moment, so show the banner on your landing pages: a visitor who leaves the campaign URL before answering is recorded without the campaign.

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.
  • denied. Nothing is recorded, 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, and removes the SDK's state from the device again; the page keeps using it in memory.

The SDK stores the choice too, and a stored choice wins over the analyticsConsent option on later visits. Call setAnalyticsConsent() for every answer your consent tool reports, including the stored answer it loads when a visitor returns. When your consent tool has no answer for the visitor, for example because its stored answer expired, call setAnalyticsConsent("pending").

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.

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.
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.

PreviousTrack eventsNextPage views and attribution

On this page

What the SDK collectsIP addressesWhat the SDK stores in the browserAsk for consent firstLocation behind a proxyErase a visitor's data