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
| Data | When the SDK collects it | Used for |
|---|---|---|
| Anonymous ID | Always. The SDK creates one per browser. | Identity, analytics |
| User ID | When you call identify() with your own user ID. | Identity, analytics |
| Email address, name, and traits | When you pass them to identify() or set them as person attributes. identify() sends them even without analytics consent. | Identity |
| Pages and product events | While 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 clicks | While analytics consent is granted. utm_* parameters, ad IDs, and Meta's _fbc and _fbp cookies. | Attribution |
| Device details | With 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 location | With 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. 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:
| Key | Contents | When it is written |
|---|---|---|
identity:distinct-id | The anonymous ID, or your user ID after identify(). | When the SDK starts, and when the identity changes. |
identity:outbox | identify() links not yet delivered, with the email, name, and traits you passed. | From identify() until Voidhash confirms the link. |
consent:analytics | The 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:session | The session ID and the time of its last event. | While analytics consent is granted. |
analytics:fb-traits | The $fbc and $fbp values last sent. | While analytics consent is granted. |
__keys__ | An index of the SDK's keys. | With the keys above. |
Also:
sessionStoragekey@voidhash/web:<publishable key>:touch: a campaign the visitor arrived from while consent ispending, 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 isdenied.- In browsers without the Web Locks API, a short-lived
localStoragekey starting withvh: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 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, events, sessions, or person attributes are recorded, stored, or sent.
- Nothing is written to
localStorageorsessionStorage. The anonymous ID, cached feature flags, andidentify()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 callsetPersonAttributesSync(), 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, 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.
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
analyticsConsentoption,pendingby 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. pendingmeans 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 agrantedanswer, sends them first.grantedrecords the page the visitor is on, once, and sends what waited;denieddiscards 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.
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 campaign touch.
- 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:
- 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.
- 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. - 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.
Coming from posthog-js
The web SDK's privacy options and their posthog-js counterparts:
| posthog-js | Voidhash web SDK |
|---|---|
before_send | analytics.beforeSend. The page's address is in the event's context, not in $current_url. |
sanitize_properties, property_denylist | analytics.beforeSend |
Rewriting $current_url, $pathname, or $referrer in before_send | analytics.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: true | analyticsConsent: "pending" |
Calling opt_in_capturing() and opt_out_capturing() from a consent tool | analyticsConsentSource: "app", and setAnalyticsConsent() from the tool |
opt_out_persistence_by_default: true | storeBeforeConsent: false |
cookieless_mode: "on_reject" | storeBeforeConsent: false. A visitor who declines is not recorded at all. |
capture_pageview: "history_change" | The default |
capture_pageview: false | analytics: { capturePageViews: false } |
| None | pauseAnalytics() and resumeAnalytics(), which stop recording without changing consent or dropping the queue |