Error Handling

Understand the errors the Voidhash SDK throws and how to handle them in your app.

Every async method on the Voidhash client rejects with a VoidhashError — a plain Error subclass whose message follows one stable convention:

CODE: original message

The CODE prefix tells you which operation failed (for example FAILED_TO_PURCHASE), and the rest of the message tells you why. The original error is preserved on error.cause, so nothing is lost when the SDK wraps a native StoreKit or Play Billing failure.

Match on the message, not the class

The SDK's error classes are internal and not exported from the package root, so instanceof checks against specific subclasses won't work in your app. Matching on the stable CODE prefix and message strings is the supported approach. For purchase failures, the wrapped error.cause additionally carries a stable _tag (for example UserCancelledError) you can branch on — see Purchases.

Enable Debug Logging First

Before digging into any error, turn on debug in your client options. It logs every HTTP request and response the SDK makes (with sensitive headers redacted), which almost always reveals the real cause faster than parsing error messages:

utils/voidhash/client.ts
import { createVoidhashClient } from "@voidhash/react-native";

export const voidhash = createVoidhashClient("vh_pk_...", {
  debug: __DEV__,
});

See Configuration for the full list of client options.

Error Codes

Each client method wraps failures with its own code:

CodeThrown by
FAILED_TO_INITIALIZE_VOIDHASH_CLIENTinit() (called by the Provider on mount)
FAILED_TO_END_VOIDHASH_CLIENTend()
FAILED_TO_GET_CURRENT_PERSONgetCurrentPerson()
FAILED_TO_SET_PERSON_ATTRIBUTESsetPersonAttributes()
FAILED_TO_SET_PERSON_ATTRIBUTES_SYNCsetPersonAttributesSync()
FAILED_TO_GET_DISTINCT_IDgetDistinctId()
FAILED_TO_IDENTIFYidentify()
FAILED_TO_RESETreset()
FAILED_TO_SIGN_OUTsignOut()
FAILED_TO_GET_FEATURE_FLAGSgetFeatureFlags()
FAILED_TO_GET_PAYWALL_FOR_LOCATIONgetPaywallForLocation()
FAILED_TO_GET_PRODUCTSgetProducts()
FAILED_TO_PURCHASEpurchase()
FAILED_TO_RESTORE_PURCHASESrestorePurchases()
FAILED_TO_FLUSH_ANALYTICSflush()
FAILED_TO_PRESENT_CODE_REDEMPTION_SHEETiosPresentCodeRedemptionSheet()
FAILED_TO_SHOW_MANAGE_SUBSCRIPTIONSiosShowManageSubscriptions()
VOIDHASH_CLIENT_NOT_INITIALIZEDAny client method called before initialization completed

VOIDHASH_CLIENT_NOT_INITIALIZED is the one you'll hit most often while wiring things up: it means a method ran before the Provider finished init(). The built-in hooks gate on initialization automatically — if you call voidhash.client methods directly, check isInitialized first:

const { client, isInitialized } = voidhash.useVoidhash();

if (isInitialized) {
  await client.restorePurchases();
}

Handling Purchase Errors

Purchases are where error handling actually matters for UX: a cancelled purchase is not a failure, and a pending purchase is not a success. All three outcomes reject with FAILED_TO_PURCHASE, so distinguish them by the rest of the message:

OutcomeMessage containsWhat to do
User cancelledUser cancelledNothing — the user changed their mind. Don't show an error.
Purchase pendingpendingPayment is deferred (e.g. Ask to Buy / parental approval). Tell the user access unlocks once it's approved.
Product not foundProduct not foundThe store SDK doesn't know the product — check your store configuration and product identifiers.
Anything elseShow a generic failure message and offer retry.

A small helper keeps this out of your components:

utils/voidhash/purchase-outcome.ts
export type PurchaseFailure = "cancelled" | "pending" | "not-found" | "failed";

/** Classifies a rejected purchase by its stable message parts. */
export function getPurchaseFailure(error: unknown): PurchaseFailure {
  if (!(error instanceof Error)) return "failed";
  if (error.message.includes("User cancelled")) return "cancelled";
  if (error.message.includes("pending")) return "pending";
  if (error.message.includes("Product not found")) return "not-found";
  return "failed";
}

With the usePurchase Hook

The hook surfaces failures through onError and its error state:

app/paywall.tsx
import { Alert } from "react-native";
import { voidhash } from "utils/voidhash/client";
import { getPurchaseFailure } from "utils/voidhash/purchase-outcome";

const { purchase, isLoading } = voidhash.usePurchase({
  onSuccess: () => {
    // Entitlements are refreshed automatically — dismiss the paywall
  },
  onError: (error) => {
    switch (getPurchaseFailure(error)) {
      case "cancelled":
        return;
      case "pending":
        Alert.alert("Almost there", "Your purchase is awaiting approval.");
        return;
      default:
        Alert.alert("Purchase failed", "Please try again.");
    }
  },
});

With try/catch

Calling the client directly works the same way:

try {
  await voidhash.client.purchase(product, {});
} catch (error) {
  if (getPurchaseFailure(error) === "cancelled") {
    return;
  }
  console.error(error);
}

Pending purchases resolve themselves

When a purchase is pending, the SDK's transaction observer picks the transaction up in the background once the store completes it — you don't need to retry. The person's entitlements update on the next sync. See Purchases.

If you present paywalls with usePaywallByLocation, purchase and restore failures triggered from inside the paywall arrive through its onError(error, { action }) callback instead, where action is "purchase" or "restore" — see Displaying Paywalls.

Errors Thrown Outside the Code Convention

A few errors are thrown directly instead of being wrapped with an operation code:

Scheme Not Set

createVoidhashClient throws synchronously — before your app even renders — with the message Scheme is not set in expo.config.ts. when no deep-link scheme is available. Set scheme in your Expo app config, or pass the scheme option explicitly. See Configuration.

Read-Only Mode

With readOnly: true (observer mode), purchase() and setPersonAttributesSync() throw immediately with:

Read-only mode is enabled. Purchasing is disabled for observer-only operation.

This is by design — in observer mode Voidhash watches transactions made by another billing SDK and must never initiate or acknowledge purchases itself. See Observer Mode.

Schema Fetch Failure

On a cold start with no cached schema and an unreachable server, init() fails and surfaces as FAILED_TO_INITIALIZE_VOIDHASH_CLIENT with the schema fetch failure as its cause. Once a schema has been fetched successfully, it is cached and revalidated in the background, so this only happens on the very first launch without connectivity.

Unsupported Platform

The iOS-only methods (iosPresentCodeRedemptionSheet(), iosShowManageSubscriptions()) fail on Android with an Unsupported platform: android message under their respective codes. Gate them behind Platform.OS === "ios".

Native Error Codes

Failures that originate in the native StoreKit (Swift) or Play Billing (Kotlin) layer carry their own ERROR_CODE: message convention. The SDK translates the common ones (USER_CANCELLED, PURCHASE_PENDING, product-lookup failures) into the friendly messages shown above; the rest pass through on the error's cause chain, so you'll mostly see these codes in logs.

iOS (StoreKit):

CodeMeaning
STOREKIT_NOT_INITIALIZEDStoreKit connection not initialized
USER_CANCELLEDUser cancelled the purchase
PURCHASE_PENDINGThe payment was deferred
PURCHASE_FAILEDPurchase operation failed
PURCHASE_UNKNOWN_RESULTUnknown purchase result
INVALID_PRODUCT_IDProduct not found in the store
TRANSACTION_NOT_FOUNDTransaction not found
TRANSACTION_VERIFICATION_FAILEDTransaction verification failed
WINDOW_SCENE_NOT_FOUNDNo window scene available for UI presentation
METHOD_NOT_AVAILABLE_TVOSMethod is not available on tvOS

Android (Play Billing):

CodeMeaning
GOOGLE_BILLING_NOT_INITIALIZEDGoogle Billing connection not initialized
BILLING_ERRORGoogle Billing operation failed
SKU_NOT_FOUNDSKU not found — fetch products first
SKU_OFFER_MISMATCHSKU count doesn't match offer tokens for subscriptions
EMPTY_SKU_LISTNo SKUs provided for the product query
CURRENT_ACTIVITY_NULLNo current activity to present from

Both platforms surface PAYWALL_PRESENTER_NOT_AVAILABLE when the native paywall presenter can't be reached.

To dig a native code out of a wrapped error, walk the cause chain:

/** Returns the deepest `CODE: message` code found on the error's cause chain. */
export function getDeepErrorCode(error: unknown): string | null {
  let code: string | null = null;
  let current: unknown = error;
  while (current instanceof Error) {
    const match = current.message.match(/^([A-Z][A-Z0-9_]+):/);
    if (match) code = match[1];
    current = current.cause;
  }
  return code;
}

Swallowing Side-Effect Errors

If you'd rather not wrap every fire-and-forget call in try/catch during early development, the unstable_swallowErrors client option converts rejections from side-effect methods (init, end, identify, reset, signOut, restorePurchases, flush, setPersonAttributes, and the iOS-only sheet methods) into console.warn output instead. Methods that return data your app depends on — getCurrentPerson, getProducts, getFeatureFlags, getPaywallForLocation, purchase, setPersonAttributesSync — still reject normally.

Alpha option

As the unstable_ prefix suggests, this option's behavior may change between releases. Prefer explicit error handling for production builds.