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");| Accessor | Returns |
|---|---|
data.get(slug) | The product, or undefined when the store doesn't return it |
data.toList() | Every product the store returned |
data.bySlug | Every 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.
status | Meaning |
|---|---|
completed | Voidhash validated the purchase and the person was refreshed. |
deferred | The store charged, but the receipt is still queued for Voidhash. Access arrives when it lands. |
cancelled | The user dismissed the store sheet. |
pending | The purchase needs approval, such as Ask to Buy. The SDK records it when the store completes it, even after a relaunch. |
disabled | The 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:
mode | What Google Play charges |
|---|---|
with-time-proration | Nothing now. The time left on the old subscription becomes time on the new one, which moves the next billing date. The default. |
charge-prorated-price | The price difference for the rest of the period, now. The billing date stays. Upgrades only. |
charge-full-price | The full price of the new subscription, now. What was left of the old one is credited to it. |
without-proration | The 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.
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
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.