VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
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
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
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

Troubleshooting

Diagnose missing events, duplicate page views, lost visitors, and SDK errors.

Most web SDK problems show up in its diagnostics, which report background work that never throws. Log them while you integrate:

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

const voidhash = createVoidhashClient({
  onDiagnostic: (diagnostic) => console.debug("[voidhash]", diagnostic),
  publishableKey: "vh_pk_...",
});

No events arrive

Check these in order.

  1. Is initialize() called? In React, the provider calls it. Elsewhere, call it once per page.
  2. Is analytics consent granted? With analyticsConsent: "pending", nothing is sent until you call setAnalyticsConsent("granted"). The ANALYTICS_CONSENT_DENIED diagnostic says so. With analyticsConsentSource: "app", consent starts as pending on every page until you pass your consent tool's answer.
  3. Is recording paused, or are events dropped on purpose? Check isAnalyticsPaused(), what analytics.beforeSend returns (BEFORE_SEND_FAILED when it throws), and whether analytics.sanitizeUrl returns null for the page.
  4. Is the publishable key right? A rejected key pauses all requests and reports AUTHENTICATION_FAILED once. Copy the key again from Settings → API keys in Studio.
  5. Does a Content Security Policy block the requests? Add https://api.voidhash.com, or your proxy's or tracking domain's origin, to connect-src. See Content Security Policy.
  6. Does an ad blocker block them? In the Network tab, look for blocked requests to /i/v1/batch. Set up a first-party proxy.
  7. Are you testing with a headless browser? Events from browsers that identify themselves as headless or as test tools, such as headless Chrome (including Playwright's and Puppeteer's default) or Cypress, are discarded as bot traffic. Test in a regular browser window.
  8. Is the event turned on? Self-hosted projects store $pageview, $pageleave, $outbound_click, and $sign_out only after they are turned on under Settings → Events.

Events are sent every 5 seconds, so allow a few seconds before they appear in Activity → Analytics events. flushAnalytics() sends them at once and resolves with { flushed, pending, lastError }. Events Voidhash rejected appear on Analytics → Data health; see Check your data.

Staging events show up as production

An event's environment is the environment of the publishable key that sent it. If your staging site's page views appear under Production, the site uses the vh_pk_... key: switch it to the vh_pk_test_... key from Settings → API keys. See API keys.

Page views are counted twice

  • Your app calls page() on every route change while automatic page views are on. Remove the calls, or set analytics: { capturePageViews: false }.
  • Two clients for the same project run on the page, for example one from a module and one from a VoidhashProvider with config. Create one client and pass it to the provider with client.

React Strict Mode does not double page views: the provider keeps one client through its remount.

A private page shows up in analytics

  • A pause came too late. The SDK records a page view just after the navigation, so a pause from the new page's component is too late. Return null for the page from analytics.sanitizeUrl instead; see Keep data out of analytics.
  • Its data is in properties or a title. sanitizeUrl does not cover what you pass to track() or setPersonAttributes(), or the title of a page you keep. Remove those in analytics.beforeSend.
  • Another client runs on the page, for example one from a VoidhashProvider with config and one of your own, and only one has the option. Create one client and pass it to the provider.

Returning visitors show up as new visitors

  • The browser cleared storage. Private windows, "clear on exit" settings and blocked storage start a new visitor each time, and Safari may delete script-written storage after about seven days without a visit. Identify signed-in users so each visit links to them again.
  • The publishable key changed. The SDK keeps its saved state per project key, so another key starts new visitors.
  • Consent is not granted and storeBeforeConsent is false. The SDK stores nothing until consent, so every page load is a new anonymous visitor. That is intended.
  • Another subdomain. localStorage is per origin, so www.example.com and app.example.com have different anonymous visitors until the visitor is identified on both, unless you turn on crossSubdomainIdentity.

Subdomains still show different visitors

With crossSubdomainIdentity on, check these:

  • Every subdomain needs the option and the same publishable key.
  • Consent must be granted on both. The SDK touches the cookie only with consent, and consent is stored per subdomain unless your consent tool shares it.
  • One side is signed in. An identified device never takes an ID from the cookie. Identify the visitor on the other subdomain too.
  • The device keeps its own ID when it has a recorded campaign, or when your app read the ID with getDistinctId(). Read it only when you need it, such as at checkout.
  • The cookie ran out or consent was withdrawn. A device that used the cookie before does not write its ID back.
  • The site is under a public suffix, such as *.vercel.app or *.github.io, or on localhost. Browsers refuse a shared cookie there; use your own domain.
  • The browser refused the cookie. The SDK reports SHARED_ID_COOKIE_REFUSED. Safari also caps the cookie at 7 days, or 24 hours on some ad landing pages.
  • History from before you turned it on stays under each subdomain's own ID.

A purchase is not credited to a campaign

  • The Checkout Session or customer carries no voidhash_distinct_id metadata, for example because the purchase came through a Payment Link. See Stripe revenue.
  • The ID was read before identify() while the visitor was signed in, so the purchase landed on the anonymous visitor. It moves to the user when that visitor is identified.
  • The visitor arrived without campaign parameters, or declined analytics consent, so they have no touch.
  • With storeBeforeConsent: false, the visitor reached another page with a full page load before they answered, which loses the campaign held in memory. Show the banner on your landing pages.
  • The visitor's last campaign visit fell outside your project's attribution window before signup. See Ad measurement.
  • The purchase was made in Stripe test mode, so it appears only in the Test environment. See Test purchases (Help center).
  • The ad's URL does not carry the campaign IDs. See Meta ads.

A campaign has no ad network

A touch only gets an ad_network from a click ID, a paid utm_medium, an ad ID, or a utm_source naming an ad platform without a utm_medium. So utm_source=newsletter and utm_source=facebook&utm_medium=social are organic, which is what you want for your own posts and emails. For an ad, set a paid utm_medium such as paid_social or cpc. See How the ad network is decided.

Errors and diagnostics

A rejected call names its cause in its error class, and background failures arrive as diagnostics with a stable code. Errors lists every class, operation code and diagnostic, with what to do about each.

Next steps

  • Errors and diagnostics
  • Check your data in Studio
  • Web SDK reference
  • Troubleshooting across platforms

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

PreviousErrorsNextNode.js library

On this page

No events arriveStaging events show up as productionPage views are counted twiceA private page shows up in analyticsReturning visitors show up as new visitorsSubdomains still show different visitorsA purchase is not credited to a campaignA campaign has no ad networkErrors and diagnosticsNext steps