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.
emailandnamefill the person's built-in fields, here and insetPersonAttributes(). Other keys become custom attributes with string, number or boolean values;nullclears one.- IDs that start with
vh_anon_orvh:anon:are reserved.identify()rejects them and blank IDs withVoidhashInvalidArgumentError.
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
| Situation | Result |
|---|---|
| New user | The anonymous visitor becomes the user. |
| Existing user, such as one from your app | The 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 in | No merge. Events from then on carry the new user. No sign-out is recorded, so call signOut() on sign-out. |
| Same user again | Nothing 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
Something wrong or missing on this page? Tell us by email or on Discord.