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:
| Field | What it is |
|---|---|
slug | The product's slug in Studio. |
type | subscription, consumable, nonConsumable or, on the App Store, nonRenewingSubscription. |
displayName | The store's title, localized for the person. |
price | { amount, amountMicros, currencyCode, formatted }. Show formatted as it is. |
offers | The discounts the person can buy it with, besides the introductory offer. |
storeProductId | The 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.
status | Meaning |
|---|---|
purchased | The 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. |
pending | The 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. |
cancelled | The person dismissed the store sheet. |
disabled | The 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:
reason | Set on | When |
|---|---|---|
ineligibleForOffer | iOS and Android | The App Store refused a promotional or win-back offer, or Google Play an offer the account can't get. |
paymentDeclined | Android | Google 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:
offerslists 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, sointroOffer.idis"". - Google Play: the offer's ID in Studio when you configured it there, otherwise Google Play's
offer ID,
introOfferincluded.
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:
mode | What Google Play charges |
|---|---|
withTimeProration | Nothing now. The time left on the old subscription becomes time on the new one, which moves the next billing date. The default. |
chargeProratedPrice | The price difference for the rest of the period, now. The billing date stays. Upgrades only. |
chargeFullPrice | The full price of the new subscription, now. What was left of the old one is credited to it. |
withoutProration | The new price, first on the next billing date, which stays. |
deferred | Nothing 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.
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:
| Method | App Store | Google Play |
|---|---|---|
client.showManageSubscriptions(product?) | Subscription management, for the product's group | The subscriptions page |
client.presentCodeRedemption(code?) | The offer code sheet | The redeem page, filled in with code |
client.getStorefront() | { countryCode, currencyCode } | The same |
client.canMakePayments() | Whether the device allows purchases | Whether 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
onDiagnosticforTRANSACTION_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.