VoidhashDocs
DevelopersHelp centerGuidesAPI reference
voidhash.comOpen Studio
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
Groups
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
Groups
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
Theme

Privacy and consent

What the web SDK collects and stores, how to ask for consent first, and how to erase a visitor.

The web SDK records analytics only while the visitor's analytics consent is granted, and you decide what it may store before then. Use this page for your privacy policy and cookie banner.

Analytics consent covers what the SDK observes: page views, events, sessions, campaigns and ad clicks. It does not cover what your app says about a person: their user ID, email, name, person attributes and groups are sent whatever the consent. Feature flags and cohorts can target a person's attributes and groups, so they must not depend on whether the person agreed to tracking. To send none of it before consent, don't call identify(), setPersonAttributes(), group() or setGroups() until then.

What the SDK collects

DataWhenUsed for
Anonymous IDAlways; one per browser.Identity, analytics
User IDWhen you call identify().Identity, analytics
Email, name and traitsWhen you pass them to identify() or set person attributes. Sent whatever the analytics consent.Identity
Groups and their propertiesWhen you call group() or setGroups(). Sent whatever the analytics consent.Identity, analytics
Pages and product eventsWhile consent is granted: page origin, path and title, referrer origin and path, and the events you track.Analytics
Campaigns and ad clicksWhile consent is granted: utm_* parameters, ad IDs, click IDs, and Meta's _fbc and _fbp cookies if your site sets them.Attribution
Device detailsWith every request: user agent, languages, platform. With events: time zone, screen and viewport size.Analytics
IP address and locationWith every event while consent is granted. Voidhash derives country, region, city, time zone and approximate coordinates.Analytics

The SDK sends data only to Voidhash, or to the proxy or tracking domain you set. It records no part of the address but the path and campaign parameters, and its requests carry at most your site's origin as Referer. Behind a proxy you run, events carry the proxy's location rather than the visitor's.

IP addresses

Voidhash keeps the full IP address of an event for 90 days, then removes it within a day. The event keeps only the network part, as $ip: 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 uses sessionStorage for a campaign seen while consent is pending. It sets a cookie only with crossSubdomainIdentity on; list that one in your cookie banner. Withdrawing consent removes every analytics key and the cookie. Browser storage lists every key and the cookie's attributes.

Ask for consent first

Where the law requires consent before a site stores an identifier or records analytics, as in the EU, start the client with consent pending, keep its state off the device, and pass every answer from your banner:

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

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

export function onConsentChange(analyticsAllowed: boolean) {
  void voidhash.setAnalyticsConsent(analyticsAllowed ? "granted" : "denied");
}

Until the visitor answers:

  • Nothing is recorded, and nothing is written to localStorage or sessionStorage. The anonymous ID, flags and identify() links live in memory, so each page load is a new anonymous visitor.
  • Page views and events from this time are dropped, unless you keep them until the visitor answers.
  • Nothing is sent unless you evaluate feature flags, call identify(), set person attributes, call group() or setGroups(), or call setAdDataSharing(false). Those requests are marked as made without consent, so Voidhash records no analytics for them and keeps device details, such as the platform and language, off the person. They still send the email, name, traits and group properties you pass; hold these calls until consent if your regime requires it.
  • The campaign the visitor arrived from is held in memory across client-side navigations. A full page load before they answer loses it, so show the banner on landing pages.

When the visitor answers granted, the SDK records from then on, stores the page's state, and records the held campaign with the time of the click. Other open tabs follow. When they answer denied, the choice is the only thing stored. Withdrawing consent later discards every queued event in every tab, removes the SDK's state from the device, and deletes the cross-subdomain cookie.

The stored choice wins over the analyticsConsent option on later visits, so your own banner asks only once. In React, useAnalytics() returns consent and setAnalyticsConsent, and re-renders when consent changes in any tab.

With the default storeBeforeConsent: true, the SDK stores its anonymous ID, cached flags, identify() links and, while pending, the campaign for the browser session. The visitor then stays one visitor across page loads before answering.

Keep events until the visitor answers

By default, what happens while consent is pending is dropped, so the page view and the first clicks of a visitor who then agrees are missing. With analyticsWhilePending: "buffer", the SDK holds those events in memory and records them when the visitor agrees:

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

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

export function onConsentChange(analyticsAllowed: boolean) {
  void voidhash.setAnalyticsConsent(analyticsAllowed ? "granted" : "denied");
}
  • Page views, the events you track and automatic events are held, each with its time and its page. Person attributes and group descriptions are not held: they are sent at once.
  • Held events stay in memory only. Nothing is stored or sent before the answer, and a reload or a full page load before the answer loses them. Client-side navigations keep them.
  • At most 100 events are held; after that the oldest is dropped.
  • granted records the held events first, in order and with their original times, for the identity, groups and session in effect at that moment. denied discards them.
  • denied never holds anything, and "drop" remains the default.

Let sign-in grant consent

If signing in to your product accepts terms that cover analytics, set grantConsentOnIdentify instead of asking signed-in users again. identify() then grants a consent that is still pending:

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

export const voidhash = createVoidhashClient({
  analyticsConsent: "pending",
  analyticsWhilePending: "buffer",
  grantConsentOnIdentify: true,
  publishableKey: "vh_pk_...",
});
void voidhash.initialize();
  • The grant happens before the link, so events held with "buffer" are recorded for the anonymous visitor that identify() then links to your user.
  • denied is never changed: a visitor who declined stays declined after signing in.
  • The grant lasts as long as the sign-in, also across page loads. signOut() and reset() first send what was recorded, waiting at most 2 seconds, then return consent to pending.
  • Any setAnalyticsConsent() call makes the choice the visitor's own. signOut() and reset() then leave it alone.

Check that your terms and the law that applies to you allow this before turning it on.

When someone rejects everything

Analytics consent and agreement to share purchases with ad platforms are separate. When a visitor rejects everything, call both:

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

setAdDataSharing(false) works whatever analytics consent says. It is stored and retried until Voidhash writes it, and records no analytics. If Voidhash has no such person yet, the SDK retries with growing waits for up to 48 hours, then drops it with an AD_DATA_SHARING_REVOCATION_DROPPED diagnostic. A browser keeps up to 20 pending revocations, so call it when the visitor answers, not on every page load. Agreeing again with setAdDataSharing(true) needs analytics consent; without it, the agreement is left out.

Use a consent tool

With a consent tool such as Cookiebot or OneTrust, make it the only source of consent with analyticsConsentSource: "app" and pass it every answer. See Connect a consent tool.

Keep private pages out of analytics

To keep pages whose address carries personal data out of analytics, return null for them from analytics.sanitizeUrl. To strip properties, use analytics.beforeSend. See Keep data out of analytics.

Erase a visitor's data

  1. Request an erasure from your backend or in Studio. It removes the person, their events and stored IP addresses, and the anonymous profiles merged into them.
  2. In their browser, call reset(), so later events no longer carry their ID.
  3. To remove everything the SDK stored in the browser too, deny consent (which deletes the cross-subdomain cookie), destroy the client and delete its keys. This also removes the consent choice, so the visitor is asked again. Repeat on each subdomain.
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);
  }
}

Coming from posthog-js

posthog-jsVoidhash web SDK
before_send, sanitize_properties, property_denylistanalytics.beforeSend. The page address is in the event's context.
Rewriting $current_url, $pathname or $referreranalytics.sanitizeUrl, which also covers a campaign's landing path
opt_in_capturing(), opt_out_capturing()setAnalyticsConsent("granted"), setAnalyticsConsent("denied")
has_opted_out_capturing()getAnalyticsConsent() !== "granted"
opt_out_capturing_by_default: trueanalyticsConsent: "pending"
Opting in and out from a consent toolanalyticsConsentSource: "app"
opt_out_persistence_by_default: true, cookieless_mode: "on_reject"storeBeforeConsent: false
cross_subdomain_cookie: truecrossSubdomainIdentity: true
capture_pageview: "history_change"The default
capture_pageview: falseanalytics: { capturePageViews: false }
NonepauseAnalytics() and resumeAnalytics()

Next steps

  • Connect a consent tool
  • Keep data out of analytics
  • Delete personal data
  • Send requests through your own domain

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

PreviousPage views and attributionNextBrowser storage
© 2026 Voidhash s.r.o.
voidhash.comPricingTermsPrivacyDiscord

On this page

What the SDK collectsIP addressesWhat the SDK stores in the browserAsk for consent firstKeep events until the visitor answersLet sign-in grant consentWhen someone rejects everythingUse a consent toolKeep private pages out of analyticsErase a visitor's dataComing from posthog-jsNext steps