VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Groups
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
React Native SDK
React Native quickstart
Display a paywall
Make and restore purchases
Check access
Identify users
Evaluate feature flags
Capture analytics
Groups
Track screens
Development mode
React Native SDK reference
Errors
Troubleshooting

Make and restore purchases

Sell products from your own screen, restore purchases, or report another SDK's purchases.

Paywalls handle purchases and restores on their own. Use these APIs to build your own purchase screen, add a Restore button, or report purchases another billing SDK makes in observer mode.

Load products

useProducts() returns the project's products as the store sees them on this device:

const { data: products, error, isLoading } = voidhash.useProducts();
const monthly = products.get("pro_monthly");
AccessorReturns
data.get(slug)The product, or undefined when the store doesn't return it
data.toList()Every product the store returned
data.bySlugEvery configured slug, mapped to its product or null

Each product has slug, displayName, displayPrice (formatted by the store for the user's storefront), price, currency, type and platform. Render displayPrice as is. A missing product usually means the store product isn't mapped, isn't approved or available in this storefront, or the store isn't connected; see Troubleshooting. The hook loads again when the project's product definitions change.

Start a purchase

usePurchase() opens the store sheet, sends the transaction to Voidhash, and refreshes the person once Voidhash accepts it. Voidhash then finishes the purchase with the App Store or acknowledges it with Google Play; consumables are consumed so they can be bought again.

import { Button } from "react-native";

import { voidhash } from "../src/lib/voidhash";

export function BuyButton() {
  const { data: products } = voidhash.useProducts();
  const { purchase, isLoading } = voidhash.usePurchase({
    onError: (error) => console.warn(error.code),
  });
  const monthly = products.get("pro_monthly");

  return (
    <Button
      disabled={!monthly || isLoading}
      title={monthly ? `Continue for ${monthly.displayPrice}` : "Loading"}
      onPress={async () => {
        if (!monthly) return;
        const result = await purchase(monthly);
        if (result.isOk() && result.value.status === "cancelled") return;
      }}
    />
  );
}

purchase() resolves to a Result and never rejects. onSuccess runs only for completed; onError for an Err; onSettled always. Callbacks passed to purchase() override the hook's.

statusMeaning
completedVoidhash validated the purchase and the person was refreshed.
deferredThe store charged, but the receipt is still queued for Voidhash. Access arrives when it lands.
cancelledThe user dismissed the store sheet.
pendingThe purchase needs approval, such as Ask to Buy. The SDK records it when the store completes it, even after a relaunch.
disabledThe client was created with enabled: false.

An Err carries FAILED_TO_PURCHASE, OFFER_UNAVAILABLE or READ_ONLY_PURCHASE_NOT_ALLOWED; see Errors.

To buy an offer, call the client with the offer ID from the product's store configuration (an App Store promotional offer or a Google Play developer offer). If the offer can't be bought, the call fails with OFFER_UNAVAILABLE and nothing is bought at the regular price.

const result = await voidhash.client.purchase(monthly, { offerId: "winback_50" });

Change a subscription

On Google Play, buying another subscription product doesn't end the one the person already has: they hold both, and Google Play bills both. To upgrade, downgrade or switch plans, name the subscription the purchase replaces:

const result = await voidhash.client.purchase(product, {
  replacing: { product: monthly, mode: "with-time-proration" },
});

product is the plan the person moves to and monthly the one they have, both from useProducts(); purchase() from usePurchase() takes the same option. The SDK finds the person's current purchase of the replaced product itself, and Google Play ends that subscription when the new one starts. The mode decides what Google Play charges:

modeWhat Google Play charges
with-time-prorationNothing now. The time left on the old subscription becomes time on the new one, which moves the next billing date. The default.
charge-prorated-priceThe price difference for the rest of the period, now. The billing date stays. Upgrades only.
charge-full-priceThe full price of the new subscription, now. What was left of the old one is credited to it.
without-prorationThe new price, first on the next billing date, which stays.

Every mode changes the subscription at once; Google Play's deferred replacement isn't available. Nothing is bought when the replacement can't be made: the Err carries REPLACED_SUBSCRIPTION_NOT_OWNED when the Google Play account has no paid purchase of the replaced product, INVALID_ARGUMENT when either product isn't a subscription, both are the same, or replacing names no product or an unknown mode, and SUBSCRIPTION_REPLACEMENT_REFUSED when Google Play refuses the replacement before its sheet opens. A purchase made in a paywall can't replace a subscription yet.

replacing only has an effect on Android. On iOS it's ignored: the App Store handles changes within a subscription group itself.

Unlock from the perk, not the store sheet

A completed store sheet isn't proof of access. Unlock features from the person's perks, for example with useHasPerk.

Restore purchases

Connect your Restore button to restorePurchases():

const result = await voidhash.client.restorePurchases();
if (result.isErr()) console.warn(result.error.code);

It revalidates the purchases the store still has for the current person, then refreshes the person. On iOS it calls AppStore.sync(), which can ask the user to sign in, so call it only from a user action. Android queries the purchases the account owns. Consumables aren't restored.

A restore is the only way to give the current person a purchase that already unlocks access for another person. The project's paid access sharing setting decides whether both keep access, access moves, or it stays; see Project settings.

A store error, or a receipt Voidhash couldn't confirm yet, fails the restore with FAILED_TO_RESTORE_PURCHASES and the receipt stays queued. Success doesn't mean the person has access: check the perk afterwards.

Google Play acknowledgement

Google Play refunds a purchase nobody acknowledges within 3 days. Voidhash acknowledges a purchase the SDK started as soon as it records it. If Voidhash can't record it in time, a background job on the device checks every 4 hours and acknowledges a purchase still unacknowledged 36 hours after payment, even while the app is closed. That job only acknowledges: a consumable stays owned until Voidhash records it and the SDK consumes it. The SDK then reports PURCHASE_FINISHED_BEFORE_SYNC through onDiagnostic and sends the receipt once Voidhash is reachable.

The SDK adds the job service and the RECEIVE_BOOT_COMPLETED permission to your manifest, so the check survives a reboot. Purchases in observer mode or reported with reportTransaction are never acknowledged by the SDK.

Report purchases in observer mode

With readOnly: true, your other billing SDK owns purchases. After each successful purchase, report it with the store identifier only:

// App Store: the transaction ID, as a string.
await voidhash.client.reportTransaction({ platform: "ios", transactionId });

// Google Play: the purchase token, not the order ID.
await voidhash.client.reportTransaction({ platform: "android", purchaseToken });

Voidhash fetches and verifies the purchase with the store. The method also accepts a StoreKit JWS string or a Play purchase object with originalJson. Other fields are ignored, and a Play purchase whose purchaseState is pending is ignored until you report it as purchased.

  • Identify the buyer before the purchase starts, so the report lands on the right person.
  • A report is written to the device before the call returns and delivered in the background. A network failure keeps it queued and still returns Ok.
  • A malformed identifier returns FAILED_TO_REPORT_TRANSACTION.
  • Reporting never finishes, acknowledges or consumes a purchase.

When the user restores through your other SDK, report each returned transaction with { isRestore: true }. It then works like restorePurchases() for the current person:

await voidhash.client.reportTransaction({ platform: "ios", transactionId }, { isRestore: true });

When a callback exposes no transaction values, call syncPurchases(). It sends queued receipts, scans what the store still lists, and refreshes the person, without prompting the user or moving a purchase to another person. A scan can't recover a consumable your other SDK already consumed, so report purchases explicitly whenever you can. The SDK also scans at launch, on foreground and on reconnect, at most once a minute.

Purchases missing in Studio

  • Check that the app uses the intended key, and that Studio shows the matching environment: TestFlight and Play test purchases appear under Sandbox, development-mode purchases under Test.
  • In observer mode, check that every purchase callback calls reportTransaction.
  • Watch onDiagnostic for TRANSACTION_REJECTED: Voidhash refused the receipt, usually because the App Store or Google Play isn't connected for this bundle ID or package name.

Next steps

  • Check access
  • Test purchases (Help center)
  • Run in observer mode
  • Errors

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousDisplay a paywallNextCheck access

On this page

Load productsStart a purchaseChange a subscriptionRestore purchasesGoogle Play acknowledgementReport purchases in observer modePurchases missing in StudioNext steps