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
| Data | When | Used for |
|---|---|---|
| Anonymous ID | Always; one per browser. | Identity, analytics |
| User ID | When you call identify(). | Identity, analytics |
| Email, name and traits | When you pass them to identify() or set person attributes. Sent whatever the analytics consent. | Identity |
| Groups and their properties | When you call group() or setGroups(). Sent whatever the analytics consent. | Identity, analytics |
| Pages and product events | While consent is granted: page origin, path and title, referrer origin and path, and the events you track. | Analytics |
| Campaigns and ad clicks | While consent is granted: utm_* parameters, ad IDs, click IDs, and Meta's _fbc and _fbp cookies if your site sets them. | Attribution |
| Device details | With every request: user agent, languages, platform. With events: time zone, screen and viewport size. | Analytics |
| IP address and location | With 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
localStorageorsessionStorage. The anonymous ID, flags andidentify()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, callgroup()orsetGroups(), or callsetAdDataSharing(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.
grantedrecords the held events first, in order and with their original times, for the identity, groups and session in effect at that moment.denieddiscards them.deniednever 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 thatidentify()then links to your user. deniedis 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()andreset()first send what was recorded, waiting at most 2 seconds, then return consent topending. - Any
setAnalyticsConsent()call makes the choice the visitor's own.signOut()andreset()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
- 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.
- In their browser, call
reset(), so later events no longer carry their ID. - 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-js | Voidhash web SDK |
|---|---|
before_send, sanitize_properties, property_denylist | analytics.beforeSend. The page address is in the event's context. |
Rewriting $current_url, $pathname or $referrer | analytics.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: true | analyticsConsent: "pending" |
| Opting in and out from a consent tool | analyticsConsentSource: "app" |
opt_out_persistence_by_default: true, cookieless_mode: "on_reject" | storeBeforeConsent: false |
cross_subdomain_cookie: true | crossSubdomainIdentity: true |
capture_pageview: "history_change" | The default |
capture_pageview: false | analytics: { capturePageViews: false } |
| None | pauseAnalytics() and resumeAnalytics() |
Next steps
Something wrong or missing on this page? Tell us by email or on Discord.