Making Purchases

Start purchases with the usePurchase hook, restore previous ones, and learn what Voidhash verifies and finishes for you.

Voidhash wraps StoreKit 2 on iOS and Google Play Billing on Android behind a single purchase API. You start a purchase with the usePurchase hook (or client.purchase outside of components), and the SDK takes care of the rest: presenting the store sheet, verifying the receipt on the server, finishing the transaction with the store, and refreshing the person's entitlements.

Starting a Purchase

Use the usePurchase hook together with useProducts:

app/paywall.tsx
import { Button, Text, View } from "react-native";
import { voidhash } from "utils/voidhash/client";

export default function PaywallScreen() {
  const { data: products, isLoading: areProductsLoading } = voidhash.useProducts();
  const { purchase, isLoading: isPurchasing, error } = voidhash.usePurchase();

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

  const monthly = products.get("pro-monthly");

  return (
    <View>
      {monthly && (
        <Button
          title={isPurchasing ? "Purchasing..." : `Subscribe for ${monthly.displayPrice}`}
          disabled={isPurchasing}
          onPress={() => purchase(monthly)}
        />
      )}
      {error && <Text>Something went wrong. Please try again.</Text>}
    </View>
  );
}

The hook returns:

  • purchase(product, options?) — starts the purchase for a product returned by useProducts
  • isLoadingtrue while a purchase started by this hook instance is in flight
  • error — the last purchase error, or null (cleared when a new purchase starts)

Using native paywalls?

If you present paywalls with usePaywallByLocation, the paywall bridge runs this exact purchase flow for you when the user taps a buy button inside the paywall. You only need usePurchase when building your own purchase UI. See Displaying Paywalls.

Purchase Callbacks

You can attach onSuccess, onError, and onSettled callbacks at the hook level, at the call level, or both — when both are set, both fire:

const { purchase, isLoading } = voidhash.usePurchase({
  onSuccess: () => {
    // Fires after the server has verified the receipt and the person was refreshed.
    router.back();
  },
  onError: (error) => {
    console.warn("Purchase failed", error);
  },
  onSettled: () => {
    // Fires after success or failure — useful for cleanup.
  },
});

// Call-level callbacks are merged with the hook-level ones:
purchase(product, {
  onSuccess: () => trackConversion(product.slug),
});

The options also accept method. "native" (purchasing through the platform store) is the only supported method today, so you can omit it; a call-level method takes precedence over the hook-level one.

By the time onSuccess fires, the purchase has already been verified by the Voidhash server and the person snapshot has been refreshed — useCurrentPerson re-renders with the new entitlements without any extra work.

Purchasing Outside Components

The same flow is available on the client instance. Note that the options object is required:

await voidhash.client.purchase(product, {});

client.purchase resolves once the full flow (store purchase, server sync, store finish, person refresh) has completed, and rejects with the errors described below.

What Happens During a Purchase

A single purchase(product) call runs this pipeline automatically:

  1. Account token derivation. The SDK derives a deterministic UUIDv5 from the current distinct id and passes it to the store — as appAccountToken on StoreKit and obfuscatedAccountId on Google Play. The same distinct id always produces the same token on both platforms, which is how server-side events (renewals, transactions observed later) get attributed to the right person. See Identifying Users.
  2. Native purchase. The platform store sheet is presented via StoreKit 2 or Play Billing.
  3. Server receipt verification. The resulting transaction is synced to the Voidhash server, which verifies it with Apple or Google and grants the resulting entitlements.
  4. Store acknowledge / finish. Only after the server accepts the transaction does the SDK finish it with StoreKit or acknowledge it with Play Billing.
  5. Person refresh. The current person snapshot is re-fetched, so hooks reading entitlements and subscription state update immediately.

Why finish only after server sync?

If the app is killed or the network drops between payment and verification, the transaction stays unfinished in the store queue. The background transaction observer picks it up and retries the sync, so a user never pays without eventually receiving their entitlement.

Background Transaction Observer

During client.init() the SDK attaches a native transaction observer and kicks off a background reconciliation pass (it never blocks init). This catches transactions that complete outside a live purchase() call — pending purchases that get approved later, purchases interrupted by a crash, or transactions whose server sync previously failed.

  • Reconciliation reads two sources: pending transactions and the store purchase history.
  • Each transaction is deduplicated client-side by platform + transactionId + purchaseDate, and the server endpoint is idempotent by store transaction identity — retries and duplicates are safe.
  • Observed transactions go through the same pipeline as a direct purchase: server sync first, store acknowledge/finish only on success, then a person refresh.
  • Consumable products (one-time-consumable) are excluded from reconciliation, so a consumed purchase is never re-processed from history.
  • On Android, observed transactions without a purchase token are skipped.

You don't need to wire anything up for this — it is part of init().

Restoring Purchases

restorePurchases() runs the same reconciliation on demand and refreshes the person afterwards:

import { Button } from "react-native";
import { voidhash } from "utils/voidhash/client";

export function RestorePurchasesButton() {
  const { client } = voidhash.useVoidhash();

  return <Button title="Restore Purchases" onPress={() => client.restorePurchases()} />;
}

Because reconciliation is deduplicated and the server endpoint is idempotent, it is safe to let users tap this as often as they like.

Apple requires a restore option

Apps offering in-app purchases are expected to provide a visible way to restore them — keep a restore button somewhere reachable, typically on your paywall and in settings.

Handling Purchase Errors

A failed purchase rejects the client.purchase promise, populates the hook's error state, and fires onError. The surfaced error is a VoidhashError whose message is prefixed with the operation code — FAILED_TO_PURCHASE: <reason> — and whose cause is the underlying failure. The cause carries a stable _tag you can branch on:

_tag on error.causeMeaningWhat to do
UserCancelledErrorThe user dismissed the store sheet.Nothing — don't show an error.
PurchasePendingErrorThe purchase needs external approval (for example Ask to Buy) or the payment is still pending.Tell the user it will complete later; the background observer finishes it once the store approves.
ProductNotFoundErrorThe store doesn't know the product id.Check your store configuration — the provider product id in your schema must exist in App Store Connect / Play Console.
NativeAdapterNotInitializedErrorThe native store connection isn't ready.Ensure the purchase runs after the Provider finished initializing.
FailedToBuyProductErrorAny other store-level failure.Show a generic retry message.
import { Alert } from "react-native";
import { voidhash } from "utils/voidhash/client";

const { purchase, isLoading } = voidhash.usePurchase({
  onError: (error) => {
    const tag = (error.cause as { _tag?: string } | undefined)?._tag;

    if (tag === "UserCancelledError") {
      return; // The user changed their mind — stay quiet.
    }

    if (tag === "PurchasePendingError") {
      Alert.alert(
        "Purchase pending",
        "Your purchase is awaiting approval and will activate automatically.",
      );
      return;
    }

    Alert.alert("Purchase failed", "Please try again.");
  },
});

For the full list of SDK errors and the CODE: message convention, see Error Handling.

Read-Only Mode

When the client is created with readOnly: true (observer mode), starting purchases is disabled: purchase() rejects immediately with a ReadOnlyModePurchaseNotAllowedError — message Read-only mode is enabled. Purchasing is disabled for observer-only operation. — before any store interaction happens. This error is thrown directly, without the FAILED_TO_PURCHASE: prefix.

In read-only mode the SDK still observes and syncs transactions to Voidhash, but it never acknowledges or finishes them with the store — that stays the responsibility of whichever billing SDK owns the purchase flow. See Observer Mode.

iOS-Only Helpers

Two StoreKit-specific helpers are available on the client:

  • client.iosPresentCodeRedemptionSheet() — presents the system sheet for redeeming subscription offer codes
  • client.iosShowManageSubscriptions() — presents the system manage-subscriptions sheet
import { Button, Platform, View } from "react-native";
import { voidhash } from "utils/voidhash/client";

export function SubscriptionSettings() {
  const { client } = voidhash.useVoidhash();

  if (Platform.OS !== "ios") {
    return null;
  }

  return (
    <View>
      <Button title="Redeem Code" onPress={() => client.iosPresentCodeRedemptionSheet()} />
      <Button title="Manage Subscription" onPress={() => client.iosShowManageSubscriptions()} />
    </View>
  );
}

On Android both methods fail with an UnsupportedPlatformError, so guard the calls with Platform.OS === "ios" as shown above.