Checking Subscription Status

Read the current person's entitlements and subscription state to decide what to unlock.

Voidhash exposes everything it knows about the current user as a single person snapshot: their identity, entitlement grants, current subscription, and purchase history. You read it reactively with useCurrentPerson() or imperatively with client.getCurrentPerson().

Reading the Person Reactively

useCurrentPerson() returns { data, error, isLoading, refetch }. It fetches the snapshot once the Provider has initialized and re-renders whenever the client publishes an updated snapshot:

app/account.tsx
import { voidhash } from "utils/voidhash/client";

export default function AccountScreen() {
  const { data: person, error, isLoading, refetch } = voidhash.useCurrentPerson();

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (error) {
    return <ErrorMessage error={error} onRetry={refetch} />;
  }

  return (
    <View>
      <Text>Person ID: {person.personId}</Text>
      <Text>Subscription: {person.subscriptions?.current?.status ?? "none"}</Text>
    </View>
  );
}

data can be empty

While the snapshot is loading — and for a brand-new anonymous user that has no server-side person yet — data is an empty object {}, not null. Always use optional chaining (person.subscriptions?.current) when reading nested fields.

Reading the Person Imperatively

Outside of React — in event handlers, background logic, or your own state layer — use client.getCurrentPerson(forceFetch?). It resolves to the snapshot, or null for a brand-new person that has not been created on the server yet:

import { voidhash } from "utils/voidhash/client";

const person = await voidhash.client.getCurrentPerson();

// Bypass the cache and always hit the server:
const fresh = await voidhash.client.getCurrentPerson(true);

If the call fails, it throws a VoidhashError with a FAILED_TO_GET_CURRENT_PERSON code — see Error Handling.

The Person Snapshot

The snapshot has this shape:

type SdkPersonSnapshot = {
  personId: string;
  distinctId: string;
  email: string | null;
  name: string | null;

  entitlements: {
    grants: Array<{
      perkId: string;
      status: "active" | "expired";
      source: "subscription" | "purchase" | "manual";
      sourceId: string | null;
      expiresAt: Date | null;
    }>;
  };

  subscriptions: {
    current: {
      status: "none" | "active" | "canceled" | "past_due" | "trialing";
      expiresAt: Date | null;
      productId: string | null;
      subscriptionId: string | null;
    } | null;
    history: Array<{
      subscriptionId: string;
      productId: string | null;
      status: "active" | "canceled" | "expired" | "trialing" | "past_due";
      startsAt: Date;
      expiresAt: Date | null;
      canceledAt: Date | null;
      isTrial: boolean;
    }>;
  };

  purchases: {
    history: Array<{
      purchaseId: string;
      productId: string | null;
      type: "one_time" | "subscription";
      providerKey: string;
      createdAt: Date;
    }>;
  };

  snapshotContext: {
    mode: "persisted" | "temporary_pending_transfer";
  };
};

Entitlements

entitlements.grants is the list of perks the person holds. Each grant names the perk from your schema (perkId), whether it is currently active or expired, where it came from (source: a subscription, a one-time purchase, or a manual grant from the dashboard), and when it expires (expiresAt is null for grants that never expire). This is the field to gate features on — it already accounts for expiry and for perks granted outside of subscriptions.

Subscriptions

subscriptions.current describes the person's current subscription, or is null/status: "none" when they have never subscribed. Use it for account UI — showing the renewal date, prompting a past_due user to fix their payment method, or messaging a trialing user. subscriptions.history lists past and present subscriptions, including canceled and expired ones.

Purchases

purchases.history lists completed purchases (both one-time purchases and subscription transactions) with the product, the payment provider (providerKey), and the purchase date.

Snapshot context

snapshotContext is mostly internal. mode is "temporary_pending_transfer" while an identify migration is merging two persons; once the migration completes it returns to "persisted".

Gating Access to a Feature

Check for an active grant of the perk you defined in your schema. A small wrapper hook keeps call sites clean:

hooks/use-has-perk.ts
import { voidhash } from "utils/voidhash/client";

export function useHasPerk(perkId: string) {
  const { data: person, error, isLoading, refetch } = voidhash.useCurrentPerson();

  const hasPerk =
    person.entitlements?.grants.some(
      (grant) => grant.perkId === perkId && grant.status === "active",
    ) ?? false;

  return { hasPerk, error, isLoading, refetch };
}
app/premium-feature.tsx
import { useHasPerk } from "hooks/use-has-perk";

export default function PremiumFeature() {
  const { hasPerk, error, isLoading } = useHasPerk("pro");

  if (isLoading) {
    return <LoadingSpinner />;
  }

  // If the status check failed, fall back to the paywall instead of
  // guessing — the user can always restore or retry from there.
  if (error || !hasPerk) {
    return <PaywallScreen />;
  }

  return <PremiumContent />;
}

Gate on grants, not on subscription status

subscriptions.current.status === "active" looks tempting, but it misses perks granted by one-time purchases and manual grants, and it does not tell you which features the subscription unlocks. Grants are the entitlement source of truth; use subscription status for account UI only.

Caching

Person snapshots are cached on-device per distinct id, so subscription checks are instant and work through flaky networks. getCurrentPerson() (and the hook) use a fetch-while-stale policy:

  • A cached snapshot fresher than 5 minutes is returned directly, with no network request.
  • A snapshot older than 5 minutes is considered stale and is re-fetched from the server.
  • Cache entries expire entirely after 2 days; an expired entry is never served.
  • getCurrentPerson(true) skips the cache and always fetches from the server.

You rarely need forceFetch because the SDK keeps the cache in sync at the moments that matter:

  • On init the person is prefetched in the background.
  • After a successful purchase or restore the SDK re-fetches the snapshot from the server, so new entitlements are reflected immediately — your next getCurrentPerson() or refetch() returns them without a stale window.
  • After identify() the cache key changes with the distinct id, so the previous identity's snapshot is never served for the new one. Call refetch() (or getCurrentPerson()) after identifying to load the new person's data.

Next Steps