Identifying Users
Learn how Voidhash tracks anonymous users and how to link them to your own user accounts.
Every device running your app gets a distinct id — the identifier Voidhash uses to attribute
purchases, subscriptions, entitlements, feature flag evaluations, and analytics events to a person.
Users start out anonymous; if your app has accounts, you can link the anonymous person to your own
user id with identify.
Anonymous vs identified users
| Anonymous | Identified | |
|---|---|---|
| Distinct id | Auto-generated, prefixed vh:anon: | Your externalUserId |
| Setup required | None — works out of the box | Call identify() after sign-in |
| Persistence | Stored on-device, survives app restarts | Tied to your account system |
| After reinstall | A new anonymous id is minted | Same person once you identify() again |
| Across devices | Each device is a separate person | Same person on every device |
| Best for | Apps without accounts, pre-login usage | Apps with authentication |
You don't have to choose one model globally. The common pattern is: let users purchase anonymously,
then call identify when (or if) they sign in — their history moves with them.
Anonymous users
No setup is needed. On first launch the SDK generates an anonymous distinct id with the vh:anon:
prefix, persists it in on-device storage, and reuses it on every subsequent launch. All events and
purchases attribute to this id until you identify the user or reset the identity.
If you already have a distinct id from a previous session or another source, you can pass it at
client creation time with the distinctId option — see
Configuration.
Identifying a user
After a user signs in with your authentication system, call identify with a unique identifier
from your backend. You can attach the reserved email and name attributes in the same call:
import { voidhash } from "utils/voidhash/client";
const handleSignIn = async (email: string) => {
// Your authentication logic
const user = await yourAuthService.signIn(email);
// Link the current (anonymous) person to your user id
await voidhash.client.identify(user.id, {
email: user.email,
name: user.name,
});
};The options object is required — pass {} if you don't want to set email or name.
Use unique, hard-to-guess identifiers
The identifier should be unique and hard to guess (like a UUID). Never use predictable values like sequential IDs or email addresses as the primary identifier.
Calling identify does a few things, in order:
- Flushes queued analytics first. Any events captured before the switch are delivered under the pre-switch distinct id, so your pre-login activity stays attributed to the anonymous person.
- Calls the Voidhash server to link the anonymous person to your
externalUserIdand returns the updated person. - Switches the persisted distinct id to
externalUserIdfor all subsequent calls. - Clears cached feature flag state, since flag evaluations are identity-scoped. Flags are re-evaluated for the new identity on the next read.
Migration state: snapshotContext.mode
When an anonymous person with existing history is identified, the server merges that history into
the identified person in the background. While the migration is in flight, person snapshots
returned by getCurrentPerson / useCurrentPerson report this through snapshotContext:
mode: "persisted"— the normal state; the snapshot reflects a single, fully settled person.mode: "temporary_pending_transfer"— an identify migration is still running. The snapshot is a merged view of both persons;snapshotContext.includedPersonIdslists them andsnapshotContext.migrationJobIdreferences the transfer job.
You usually don't need to act on this — entitlements are already merged in the snapshot — but it
explains why personId values can differ between snapshots taken during and after a migration.
Signing out
When a user signs out of your app, sign them out of Voidhash too:
const handleSignOut = async () => {
// Your sign-out logic
await yourAuthService.signOut();
// Sign out from Voidhash
await voidhash.client.signOut();
};signOut() captures the built-in $sign_out analytics event, flushes the queue so it (and any
pending events) attribute to the signing-out identity, and then resets the identity. The next call
that needs a distinct id mints a fresh anonymous vh:anon: id.
reset()
reset() is the lower-level primitive: it flushes queued analytics under the current identity,
clears the on-device cache and person/feature-flag state, and mints a fresh anonymous distinct id —
without capturing $sign_out. Prefer signOut() for user-initiated sign-outs; use reset() when
you want to discard the current identity silently.
Reading the current distinct id
const distinctId = await voidhash.client.getDistinctId();Returns the active distinct id — either the anonymous vh:anon: id or the externalUserId you
identified with. Useful for logging, support tooling, or passing to your backend.
Person attributes
Beyond email and name at identify time, you can attach attributes to the current person at any
point — anonymous or identified. There are two variants:
setPersonAttributes | setPersonAttributesSync | |
|---|---|---|
| Delivery | Fire-and-forget via the analytics queue ($set) | Immediate network round-trip |
| Returns | void | The updated person snapshot |
| Read-only mode | Allowed | Blocked (throws) |
| Use when | You just want the data recorded | You need the updated person right away |
setPersonAttributes
The default choice. The update rides the analytics queue as a $set event and is delivered by the
queue's normal batching — call flush() if you need it delivered promptly:
await voidhash.client.setPersonAttributes({
email: "ada@example.com",
name: "Ada Lovelace",
favorite_team: "sharks",
onboarding_completed: true,
});email and name are reserved keys that map to the dedicated person fields on the server.
Every other key is stored as a custom trait. Values can be strings, numbers, booleans, or
null.
setPersonAttributesSync
Performs the network sync immediately and returns the updated person snapshot:
const person = await voidhash.client.setPersonAttributesSync({
email: "ada@example.com",
});
console.log(person.email);Blocked in read-only mode
setPersonAttributesSync is a server write, so it is blocked when the client runs with
readOnly: true — mirroring purchase. The queued setPersonAttributes variant is the one to
use in observer mode.
Next steps
- Subscription Status — read the current person's entitlements and subscriptions.
- Analytics — capture custom events and understand the automatic
$app_*lifecycle events. - Configuration —
distinctId,readOnly, and other client options. - Error Handling — how identity methods surface failures.