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

AnonymousIdentified
Distinct idAuto-generated, prefixed vh:anon:Your externalUserId
Setup requiredNone — works out of the boxCall identify() after sign-in
PersistenceStored on-device, survives app restartsTied to your account system
After reinstallA new anonymous id is mintedSame person once you identify() again
Across devicesEach device is a separate personSame person on every device
Best forApps without accounts, pre-login usageApps 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 externalUserId and returns the updated person.
  • Switches the persisted distinct id to externalUserId for 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.includedPersonIds lists them and snapshotContext.migrationJobId references 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:

setPersonAttributessetPersonAttributesSync
DeliveryFire-and-forget via the analytics queue ($set)Immediate network round-trip
ReturnsvoidThe updated person snapshot
Read-only modeAllowedBlocked (throws)
Use whenYou just want the data recordedYou 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.
  • ConfigurationdistinctId, readOnly, and other client options.
  • Error Handling — how identity methods surface failures.