VoidhashDocs
DevelopersHelp centerGuidesAPI reference
voidhash.comOpen Studio
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
Theme

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 products the store sells this person on this device, sorted by slug. Pass slugs to get those products, in that order:

const {
  products: [yearly, monthly],
  error,
  isLoading,
} = voidhash.useProducts(["pro_yearly", "pro_monthly"]);

A product the store doesn't return is undefined. That usually means the store product isn't mapped, isn't approved or available in this storefront, or the store isn't connected; the SDK also reports it through onDiagnostic as PRODUCT_NOT_FOUND, with the slug and the store's reason. See Troubleshooting.

Without slugs, useProducts() returns every product in products, and data.get(slug) looks one up. All mounted hooks share one load, which runs again when the person changes, after a purchase, and when the project's products change, so offers and trials always match the person. Outside React, voidhash.client.getProducts() returns the same.

Every product has:

FieldWhat it is
slugThe product's slug in Studio.
typesubscription, consumable, nonConsumable or, on the App Store, nonRenewingSubscription.
displayNameThe store's title, localized for the person.
price{ amount, amountMicros, currencyCode, formatted }. Show formatted as it is.
offersThe discounts the person can buy it with, besides the introductory offer.
storeProductIdThe App Store or Google Play product ID.

A subscription also has subscription: its period (such as { unit: "month", count: 1 }), the free trial or introductory price the person can get right now as introOffer, and the regular price per day, week, month and year, rounded and formatted by the store's locale, for "just $1.92 a week" labels:

import { Text } from "react-native";

function PlanLabel() {
  const {
    products: [yearly],
  } = voidhash.useProducts(["pro_yearly"]);
  if (yearly?.type !== "subscription") return null;

  const { introOffer, pricePerWeek } = yearly.subscription;
  const trial = introOffer?.phases[0];
  const lead = trial?.paymentMode === "freeTrial" ? "Free trial, then " : "";
  return (
    <Text>
      {lead}
      {yearly.price.formatted} a year ({pricePerWeek.formatted} a week)
    </Text>
  );
}

After you run voidhash-cli types generate, each product is typed by its slug, so a subscription's slug gives a subscription without the type check: check only that it loaded (yearly?.subscription). A one-time product is a nonConsumable, or a nonRenewingSubscription where the App Store sells it as one. A product the store sells as another type than Studio's is left out and reported to onDiagnostic, like one the store didn't return.

product.appStore and product.googlePlay hold the store's own view of the product, with the store's field names: the App Store subscription group and every promotional and win-back offer, or every Google Play base plan and offer with its token and tags.

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 {
    products: [monthly],
  } = voidhash.useProducts(["pro_monthly"]);
  const { purchase, pendingProduct } = voidhash.usePurchase({
    onError: (error) => console.warn(error.code),
  });

  return (
    <Button
      disabled={!monthly || pendingProduct !== undefined}
      title={monthly ? `Continue for ${monthly.price.formatted}` : "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 for purchased; onError for an Err; onSettled always. Callbacks passed to purchase() override the hook's. pendingProduct is the product being bought; pressing Buy again for it with the same options answers the same purchase, and with other options (another offer, say) fails with PURCHASE_IN_PROGRESS.

statusMeaning
purchasedThe store charged. transaction describes it. synced is true once Voidhash accepted it, false while it waits on the device; access arrives when it lands. Don't sell it again.
pendingThe purchase needs approval, such as Ask to Buy, or a payment that is still pending. The SDK records it when the store completes it, even after a relaunch.
cancelledThe person dismissed the store sheet.
disabledThe client was created with enabled: false.

One purchase runs at a time: another one started meanwhile fails with PURCHASE_IN_PROGRESS. An Err can also carry FAILED_TO_PURCHASE, OFFER_UNAVAILABLE or READ_ONLY_PURCHASE_NOT_ALLOWED; see Errors. FAILED_TO_PURCHASE has error.reason set when the store said why:

reasonSet onWhen
ineligibleForOfferiOS and AndroidThe App Store refused a promotional or win-back offer, or Google Play an offer the account can't get.
paymentDeclinedAndroidGoogle Play declined the payment for insufficient funds. The App Store doesn't report declined payments.

Choose an offer

A subscription is bought with the free trial or introductory price the person is eligible for, subscription.introOffer, when there is one. Pass offer to choose:

// The regular price, without the free trial.
await voidhash.client.purchase(monthly, { offer: "none" });

// A discount from `monthly.offers`, by the offer or by its ID.
await voidhash.client.purchase(monthly, { offer: "winback_50" });

An offer's id depends on the store:

  • App Store: offers lists the promotional offers you configured in Studio, under their Studio ID. Voidhash signs no other promotional offer, so offers you didn't configure aren't listed. On iOS 18 and later it also lists the win-back offers the person can get, under their App Store offer ID. The App Store gives introductory offers no ID, so introOffer.id is "".
  • Google Play: the offer's ID in Studio when you configured it there, otherwise Google Play's offer ID, introOffer included.

On iOS id alone isn't unique, so key a list of offers by offer.kind and offer.id together. If the offer can't be bought, the purchase fails with OFFER_UNAVAILABLE and nothing is bought at the regular price.

On Google Play, trial eligibility belongs to the Google account. Google Play shows developer-made offers to everyone, so the SDK picks the offer on the base plan tagged voidhash-intro, or else the longest free trial, as introOffer, and never a paid discount. Tag an offer meant for some people only voidhash-ignore. A purchase of an offer the account can't get fails with ineligibleForOffer, and the products reload.

Other options: quantity buys several of an App Store consumable at once (on Google Play the person picks the quantity in the store's sheet), appStore: { simulatesAskToBuyInSandbox } tests Ask to Buy, and googlePlay: { offerToken, obfuscatedProfileId, isOfferPersonalized } passes Google Play's own values, with offerToken buying exactly that offer.

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: "withTimeProration" },
});

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
withTimeProrationNothing now. The time left on the old subscription becomes time on the new one, which moves the next billing date. The default.
chargeProratedPriceThe price difference for the rest of the period, now. The billing date stays. Upgrades only.
chargeFullPriceThe full price of the new subscription, now. What was left of the old one is credited to it.
withoutProrationThe new price, first on the next billing date, which stays.
deferredNothing now. The old subscription runs until it renews, into the new plan.

Every mode but deferred changes the subscription at once. A deferred change shows on the subscription as renewsToProductSlug until the renewal. 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.

Purchases that complete later

Some purchases complete outside purchase(): an approved Ask to Buy, a payment that was pending, a redeemed offer code, a purchase started from the App Store, or on iOS a renewal (transaction.isRenewal). Google Play doesn't report renewals to the device, so on Android they show up in the person's subscriptions only. A purchase or restore made on a paywall is answered by presentFlow() instead. The SDK records each one and tells your listeners, for a thank-you message:

const unsubscribe = voidhash.client.addTransactionListener(({ transaction }) => {
  if (transaction.productSlug === "pro_monthly") showThankYou();
});

Access still comes from the person's perks, which hooks re-render with.

Restore purchases

Connect your Restore button to useRestorePurchases(). It tracks the restore's loading state and error, and restore takes no arguments, so it can go straight to onPress:

import { Alert, Button } from "react-native";

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

export function RestoreButton() {
  const { restore, isLoading } = voidhash.useRestorePurchases({
    onSuccess: (person) => {
      const hasPro = person?.entitlements.grants.some(
        (grant) => grant.perkSlug === "pro" && grant.status === "active",
      );
      Alert.alert(hasPro ? "Purchases restored" : "Nothing to restore");
    },
    onError: (error) => Alert.alert("Restore failed", error.message),
  });

  return <Button disabled={isLoading} title="Restore purchases" onPress={restore} />;
}

restore() resolves to a Result with the refreshed person, or null when there is none, and never rejects. onSuccess receives the person, onError the error, and onSettled always runs. Outside React, call voidhash.client.restorePurchases(), which resolves to the same Result.

A restore 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.

Subscription management

useSubscriptions() lists the person's subscriptions that haven't ended, as Voidhash knows them across devices and stores, for a "Manage subscription" screen:

import { Text } from "react-native";

function SubscriptionStatus() {
  const { current } = voidhash.useSubscriptions();
  if (current === undefined) return null;
  if (current.status === "inGracePeriod" || current.status === "inBillingRetry") {
    return <Text>Your payment didn't go through. Update it to keep Pro.</Text>;
  }
  const date =
    current.expiresAt === undefined ? "" : new Date(current.expiresAt).toLocaleDateString();
  return <Text>{current.willRenew ? `Renews on ${date}` : `Ends on ${date}`}</Text>;
}

all lists every such subscription, including ones in a grace period, in billing retry, on hold or paused, with status, expiresAt, willRenew, renewsToProductSlug for a pending plan change, and pendingPrice for an announced price increase. Access comes from perks, never from this list. See Check access.

These open the store's own screens on both platforms:

MethodApp StoreGoogle Play
client.showManageSubscriptions(product?)Subscription management, for the product's groupThe subscriptions page
client.presentCodeRedemption(code?)The offer code sheetThe redeem page, filled in with code
client.getStorefront(){ countryCode, currencyCode }The same
client.canMakePayments()Whether the device allows purchasesWhether billing is available

voidhash.appStore holds what only the App Store has, such as beginRefundRequest(transactionId), subscriptionStatus(groupId) (every status the Apple account has in the group, the person's own first, then others, Family Sharing included), currentEntitlements() and appTransaction(), and voidhash.googlePlay what only Google Play has, such as showInAppMessages(), which lets the person fix a declined payment, and queryPurchases(). On the other platform they fail with UNSUPPORTED_PLATFORM.

The App Store shows its own messages, such as a price increase to agree to; by default the SDK holds them while a paywall is on screen (appStoreMessages: "automatic" shows them at once). A purchase the person starts on the App Store, such as a promoted in-app purchase, is bought once the SDK has started; with purchaseIntents: "manual", voidhash.appStore.addPurchaseIntentListener() receives it to buy with purchase() when your app is ready. One that arrives before you add the listener waits for it: the SDK holds the 10 latest and hands them to the first listener you add.

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
© 2026 Voidhash s.r.o.
voidhash.comPricingTermsPrivacyDiscord

On this page

Load productsStart a purchaseChoose an offerChange a subscriptionPurchases that complete laterRestore purchasesSubscription managementGoogle Play acknowledgementReport purchases in observer modePurchases missing in StudioNext steps