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.
- Is
initialize()called? In React, the provider calls it. Elsewhere, call it once per page. - Is analytics consent granted? With
analyticsConsent: "pending", nothing is sent until you callsetAnalyticsConsent("granted"). TheANALYTICS_CONSENT_DENIEDdiagnostic says so. WithanalyticsConsentSource: "app", consent starts aspendingon every page until you pass your consent tool's answer. - Is recording paused, or are events dropped on purpose? Check
isAnalyticsPaused(), whatanalytics.beforeSendreturns (BEFORE_SEND_FAILEDwhen it throws), and whetheranalytics.sanitizeUrlreturnsnullfor the page. - Is the publishable key right? A rejected key pauses all requests and reports
AUTHENTICATION_FAILEDonce. Copy the key again from Settings → API keys in Studio. - Does a Content Security Policy block the requests? Add
https://api.voidhash.com, or your proxy's or tracking domain's origin, toconnect-src. See Content Security Policy. - Does an ad blocker block them? In the Network tab, look for blocked requests to
/i/v1/batch. Set up a first-party proxy. - 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.
- Is the event turned on? Self-hosted projects store
$pageview,$pageleave,$outbound_click, and$sign_outonly 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 setanalytics: { capturePageViews: false }. - Two clients for the same project run on the page, for example one from a module and one from a
VoidhashProviderwithconfig. Create one client and pass it to the provider withclient.
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
nullfor the page fromanalytics.sanitizeUrlinstead; see Keep data out of analytics. - Its data is in properties or a title.
sanitizeUrldoes not cover what you pass totrack()orsetPersonAttributes(), or the title of a page you keep. Remove those inanalytics.beforeSend. - Another client runs on the page, for example one from a
VoidhashProviderwithconfigand 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
storeBeforeConsentisfalse. The SDK stores nothing until consent, so every page load is a new anonymous visitor. That is intended. - Another subdomain.
localStorageis per origin, sowww.example.comandapp.example.comhave different anonymous visitors until the visitor is identified on both, unless you turn oncrossSubdomainIdentity.
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.appor*.github.io, or onlocalhost. 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_idmetadata, 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
Something wrong or missing on this page? Tell us by email or on Discord.