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

Identify users

Link anonymous visitors to your users, so web and app activity belong to one person.

Every visitor starts with an anonymous distinct ID, vh_anon_<uuid>, stored in localStorage. When they sign up or sign in, identify() links that anonymous visitor to your user ID, so their earlier page views, the campaign that brought them and everything after belong to one person. People and identity explains how Voidhash merges people.

Identify after sign-in

Pass your own stable user ID, plus optional traits:

await voidhash.identify("user_123", {
  email: "ada@example.com",
  name: "Ada Lovelace",
});
  • Use an opaque, unique ID, such as a UUID, that your server and mobile app also use. Avoid emails and sequential database IDs.
  • email and name fill the person's built-in fields, here and in setPersonAttributes(). Other keys become custom attributes with string, number or boolean values; null clears one.
  • IDs that start with vh_anon_ or vh:anon: are reserved. identify() rejects them and blank IDs with VoidhashInvalidArgumentError.

identify() switches the browser to the user at once and never waits for the network. Events already recorded keep their anonymous ID, feature flags are evaluated again for the user, and React hooks re-render with the new distinctId. Other open tabs of your site follow immediately.

The link itself is stored in the browser and delivered in the background, retried until Voidhash confirms it, across reloads and closed tabs. A browser keeps up to 20 undelivered links. With storeBeforeConsent: false and no consent yet, the link lives in memory for the page only, so identify signed-in users on every page load.

Follow your authentication state

Tell the SDK what your authentication state says, whenever you have it: identify() when there is a user, signOut() when there is none. Every page load is fine. You don't need to compare IDs or remember what you called before:

  • identify() with the same ID and the same traits as the last call sends nothing and refreshes no flags, on this page and on later ones. Changed traits are sent.
  • signOut() does nothing when nobody is signed in: the visitor keeps their anonymous ID and groups.
  • The calls take effect in the order you make them, whether or not you await each one.
if (user) {
  await voidhash.identify(user.id, { email: user.email });
} else {
  await voidhash.signOut();
}

This also covers sign-ins on pages without the SDK, such as a hosted sign-in page. In React, run it in an effect:

import { useVoidhash } from "@voidhash/web/react";
import { useEffect } from "react";

import { useAuth } from "./auth"; // your authentication state

export function useVoidhashIdentity() {
  const { client, isInitialized } = useVoidhash();
  const { user, isLoading } = useAuth();

  useEffect(() => {
    if (!isInitialized || isLoading) return;
    if (user) {
      void client.identify(user.id, { email: user.email });
    } else {
      void client.signOut();
    }
  }, [client, isInitialized, isLoading, user]);
}

Wait until your authentication state has loaded: a session that is still loading is not a signed-out session.

If your users belong to companies or workspaces, pass the full set to setGroups() in the same place. It, too, sends something only when a group changed.

What identify merges

SituationResult
New userThe anonymous visitor becomes the user.
Existing user, such as one from your appThe anonymous visitor is merged into the user; the older person is kept with all page views, campaigns and purchases.
Another user ID while a user is signed inNo merge. Events from then on carry the new user. No sign-out is recorded, so call signOut() on sign-out.
Same user againNothing is merged. Changed email, name or traits are written; a call with the same ones, or without any, sends nothing.

A retried link changes nothing the second time.

Keep web and app users the same person

Call identify() with the same user ID on the web and in your iOS, Android or React Native app. The order does not matter: whichever side identifies first creates the user, and the other side's anonymous visitor is merged into it. Revenue from Stripe, the App Store and Google Play then lands on one person. See Web-to-app handoff.

Sign out

Sign out of Voidhash after your own sign-out completes:

await auth.signOut();
await voidhash.signOut();

signOut() records $sign_out, then starts a new anonymous visitor and session. When nobody is signed in it does nothing, so you can call it from every signed-out state.

reset() does exactly the same for a signed-in user. The two differ only when nobody is signed in: signOut() does nothing, and reset() starts a fresh anonymous visitor, with a new ID, no groups and a new session, without recording $sign_out. Use reset() on a shared device, for a guest's "start over", and in tests.

The visitor's analytics consent choice survives both; it belongs to the browser, not the user. The exception is a consent that identify() granted with grantConsentOnIdentify: it lasts as long as the sign-in, and both calls return it to pending.

Set person attributes

setPersonAttributes() queues attributes, so they are retried and kept across page loads. Like identify(), it is sent whatever the analytics consent: attributes are what your app says about the user, and feature flags and cohorts can target them.

await voidhash.setPersonAttributes({ plan_source: "referral" });

setPersonAttributesSync() writes now and returns the updated person. It needs the network:

const person = await voidhash.setPersonAttributesSync({ plan_source: "referral" });

Seed the identity

The distinctId option starts the client with an ID you already know:

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

const voidhash = createVoidhashClient({
  distinctId: "user_123",
  publishableKey: "vh_pk_...",
});

A browser without a stored identity starts as that ID. A browser with another identity is identified as the user during initialize(), as identify() would. An anonymous seed is used only on a browser without a stored identity, as two anonymous visitors cannot be merged.

Next steps

  • Sell on the web
  • Web-to-app handoff
  • Track events

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

PreviousScript tagNextEvaluate feature flags
© 2026 Voidhash s.r.o.
voidhash.comPricingTermsPrivacyDiscord

On this page

Identify after sign-inFollow your authentication stateWhat identify mergesKeep web and app users the same personSign outSet person attributesSeed the identityNext steps