React Native SDK
Configure @voidhash/react-native and look up its provider, hooks, and client.
The React Native SDK, @voidhash/react-native, adds entitlements, feature flags, analytics, and
observer-mode transaction reporting to Expo development builds and bare React Native apps on iOS
and Android. Use this page to create the client, set its options, and find the provider fields,
hooks, and client methods you need. Each feature also has its own guide, linked from the section
that introduces it.
The initial release is observer-only
SDK-started purchases and hosted paywalls are temporarily unavailable. Store transactions are still observed and submitted to Voidhash for revenue analytics, but the SDK never finishes or acknowledges them.
Compatibility
The SDK has the following requirements.
| Requirement | Version |
|---|---|
| Platforms | iOS and Android. |
| Expo SDK | Verified on 55. Developed against 54. Bare React Native apps do not need Expo. |
| React Native | Verified on 0.83. Developed against 0.81. |
react-native-nitro-modules | Peer ^0.37.1. Use a 0.37.x release. |
effect | Peer 4.0.0-rc.115. Install this exact version. |
| Google Play Billing Library | 9. The SDK adds it on Android, so the app's own billing library must support Billing 8 or 9. |
react-native-iap, if used | 16.6.2 or later. Earlier releases require an older Nitro minor or an older Billing Library. |
react and react-native are also peer dependencies. expo is an optional peer that only the
config plugin uses.
Nitro versions must match across modules
Every Nitro-based module in the app resolves against one shared native runtime. Mixing Nitro versions across modules produces native build or load failures that do not point back at Voidhash. Check every dependency that ships Nitro specs before upgrading one of them.
The package ships native code, so changing it requires a new development build. Expo Go cannot load
it. On iOS, the app's Podfile must reference the VoidhashCore pod. The
quickstart shows the config plugin for Expo apps and the Podfile
line for bare React Native apps.
Data collection
Account for the data the SDK sends to Voidhash when you complete the App Store privacy details and the Google Play Data safety form.
| Data | When the SDK collects it | Used for |
|---|---|---|
| Device ID | Always. The SDK creates an anonymous ID for each app install. | App functionality, analytics |
| User ID | When you call identify() with your own user ID. | App functionality, analytics |
| Email address and name | Only when you pass them to identify() or set them as person attributes. | App functionality |
| Purchase history | Always. The SDK reports the store transactions it observes. | App functionality, analytics |
| Product interaction | By default. Captured events, app lifecycle events, and screen views. | Analytics |
Requests also carry device and app details such as the device model, OS version, app version, and locale. The data is linked to the customer's identity in Voidhash. The SDK does not read advertising identifiers and does not share data with other companies for tracking.
Create the client
Create one client and keep it for the lifetime of the app.
import { createVoidhashClient } from "@voidhash/react-native";
export const voidhash = createVoidhashClient("vh_pk_...");The publishable key is safe to include in the app. Never ship vh_sk_... secret keys.
createVoidhashClient takes the publishable key and an options object. There is no schema
argument, because the schema lives on the server and the SDK fetches it when the provider mounts.
Client options
Pass an options object as the second argument to createVoidhashClient to change the defaults.
Each option is described below.
| Option | Default | Description |
|---|---|---|
scheme | First URL scheme of the app | Deep-link scheme for purchase callbacks. Read natively when omitted. |
distinctId | Persisted or new anonymous ID | Seeds the initial customer identity. Usually omit it and call identify(). A blank value is ignored. |
debug | false | Enables additional SDK diagnostics. |
dev | false | Reserved for SDK-started test purchases. |
enabled | true | Set it to false to ship the SDK fully inert. Fixed at construction. |
readOnly | true | Forced on while commerce features are unavailable. |
baseUrl | https://api.voidhash.com | Overrides the API origin for self-hosted deployments. |
ingestUrl | Same origin as baseUrl | Overrides only the analytics origin. |
baseUrl and ingestUrl must be absolute http:// or https:// URLs, and a blank value counts as
not set. In a development build, any other value throws an INVALID_ARGUMENT error from
createVoidhashClient. A release build falls back to the default origin and reports an
INVALID_CONFIGURATION diagnostic through onDiagnostic, so a mistyped environment variable
cannot silently stop every request.
Disabled clients
With enabled: false, every method is inert. The client never connects to the native store, never
opens a network connection, and never registers a listener. init() and every side-effect method
do nothing, reads answer with their empty shape, and the scheme requirement is waived.
Mount the provider unconditionally either way. Every hook still mounts on a disabled client, so hook order never changes between a flagged-off and a flagged-on build. To enable the SDK later, create a new client. That is cheap, because a disabled client never built its runtime.
Observer mode
The initial release always uses observer mode, so client.isReadOnly is always true. Passing
readOnly: false or calling client.setReadOnly(false) cannot transfer store ownership to
Voidhash yet.
Observed and restored transactions are submitted to Voidhash, but the SDK never finishes or
acknowledges them. purchase() returns READ_ONLY_PURCHASE_NOT_ALLOWED. Reads,
restorePurchases(), identity, feature flags, and analytics keep working.
After a successful host purchase, call reportTransaction(...) with its store transaction values.
A valid report is persisted before it resolves and delivered in the background, so a network or
service outage delays reporting without making the purchase flow return an error. A report made
during launch waits for the client to initialize. Use
syncPurchases() for callbacks without transaction values and for restore recovery. Store observers
and scans cannot recover a consumable already finished or consumed by the host.
See Report and restore purchases for callback examples, error
handling, and scan limits.
Provider
Mount <voidhash.Provider> above the rest of your app. It initializes identity, the schema,
person state, the store adapters, and the transaction observer. While it initializes, hooks report
isLoading: true.
<voidhash.Provider>
<App />
</voidhash.Provider>useVoidhash() reads the provider's state. It returns the fields below.
| Field | Type | Description |
|---|---|---|
status | "initializing" | "ready" | "failed" | "disabled" | The current lifecycle state. disabled is terminal. |
initError | Error | null | The initialization error. Set only while status is failed. |
retryInit | () => void | Re-runs init(). Does nothing unless status is failed. |
isInitialized | boolean | An alias for status === "ready". |
client | VoidhashClient | The underlying imperative client. |
A cold start with no network still initializes, from the state saved on the device. Initialization fails when the app binary does not contain the SDK's native modules, for example after an over-the-air update to a build made with an older SDK version. Handle that case rather than leaving the app in a permanent loading state.
const { status, initError, retryInit } = voidhash.useVoidhash();
if (status === "failed") {
return <RetryScreen error={initError} onRetry={retryInit} />;
}On iOS, an app can be launched in the background after the device restarts but before the user
unlocks it, for example by a VoIP push or a location event. The SDK cannot read its saved state until
that first unlock, so the provider stays initializing until then. Events you capture meanwhile are
kept and sent later, and the user keeps their existing identity and access once the device is
unlocked.
Apps with several React roots, such as react-native-navigation screens or brownfield views, mount a provider in each root. The providers share one client: it starts with the first provider and stops when the last one unmounts.
Hooks
The SDK exposes five hooks. The data hooks report isLoading: true while the provider initializes
and until their first read answers, and false for a disabled client. Their results are plain
values: error is null when there is none. After identify() or reset(), mounted hooks read
again for the new person.
| Hook | Description |
|---|---|
useProducts() | Reads store-backed product metadata. |
useHasPerk(slug) | Checks whether the current person holds an active grant for a perk. |
useCurrentPerson() | Reads entitlements, subscription state, and purchase history. |
useFeatureFlags(keys?) | Evaluates flags and variants for the current person. |
useVoidhash() | Reads provider state and the underlying client. |
useHasPerk
useHasPerk is the fastest way to gate a feature. Pass the perk slug configured in Studio and read
hasAccess. Grants carry that slug as perkSlug; perkId is the perk's internal ID.
const { hasAccess, grant, isLoading, isStale, error, refetch } = voidhash.useHasPerk("premium");See Check access for how the hook behaves offline.
useCurrentPerson
useCurrentPerson returns { data, error, isLoading, refetch }. data is the full person
snapshot. It is null until the first snapshot loads, and stays null while the client is
disabled. The snapshot has the following shape.
type Person = {
personId: string;
distinctId: string;
name: string | null;
email: string | null;
entitlements: { grants: Grant[] };
subscriptions: {
current: {
productId: string | null;
status: string;
subscriptionId: string | null;
expiresAt: Date | null;
} | null;
history: SubscriptionEntry[];
};
purchases: { history: PurchaseEntry[] };
snapshotContext: {
mode: "persisted" | "temporary_pending_transfer";
includedPersonIds: string[];
migrationJobId: string | null;
};
};Reads are cache-first: a snapshot younger than five minutes is served from the cache without a
request, and an older one is served while a fresh copy loads in the background. The cache lives in the same native store the
Swift and Kotlin SDKs use (UserDefaults on iOS, SharedPreferences on Android), so no extra
storage package is needed. Queued analytics events and purchase reports are kept in their own files
in the app's private storage. The cached copy survives up to two days, which is why access checks fail open offline.
The SDK refreshes the snapshot on its own at launch, on foreground, and after purchases, restores,
and identity changes, and the hooks re-render when a refresh lands.
Imperative client
Outside React, voidhash.client exposes the same core workflows as the hooks.
await voidhash.client.identify(user.id);
const products = await voidhash.client.getProducts();
const person = await voidhash.client.getCurrentPerson();
const access = await voidhash.client.hasPerk("premium");
const hasAccess = access.isOk() && access.value.hasAccess;
await voidhash.client.restorePurchases();
voidhash.client.capture("screen_viewed", { screen: "home" });Pass getCurrentPerson({ forceFetch: true }) to bypass the cache. identify() also accepts
optional { email, name } attributes.
Generated slug types
Run the CLI after you change products or perks in Studio to regenerate the slug types.
npx voidhash-cli types generateThe generated declaration augments the SDK's slug types, so product and perk slugs are checked at
compile time. Without it, the SDK still works, but slugs fall back to string.
Error handling
Hooks expose an error field. Client methods return a Result value from the better-result
library, so they never reject. Keep transport failures separate from negative product state, such as
a person who simply has no active grant.
const { data: person, error, isLoading, refetch } = voidhash.useCurrentPerson();
if (isLoading) return <LoadingScreen />;
if (error) return <RetryScreen onRetry={refetch} />;
return <Account person={person} />;Every error carries a stable code, such as FAILED_TO_GET_CURRENT_PERSON. Match on
result.error.code when recovery differs by error, and report the full error for everything else.
The complete code list with recovery guidance lives on the Errors page.