Make and restore purchases
Sell products from your own purchase screen, or report purchases from another billing SDK.
Paywalls already handle purchase and restore actions. Use the APIs on this page when you build your own purchase screen, or when another billing SDK keeps owning purchases in observer mode.
Load products
useProducts() returns the store-backed products configured in your project. Prices come already
formatted for the customer's storefront, so you can render them directly.
const { data: products, error, isLoading } = voidhash.useProducts();
const monthly = products.get("monthly");data.get(slug) returns the product for one of the project's product slugs, and data.toList()
returns every product the store returned. data.bySlug maps every configured slug to its product,
or null. monthly is undefined when the store does not return that product. That usually means
the product is not available in the current sandbox account, country, app version, or provider
configuration. The hook loads again when your project's product configuration changes, so a screen
opened on a first launch fills in once the configuration arrives.
Once you have a product, render its store-provided displayPrice.
if (!monthly) return null;
return <Text>{monthly.displayPrice}</Text>;Start a purchase
usePurchase() opens the native store sheet, sends the transaction to Voidhash for validation, and
refreshes the current person once Voidhash accepts it, so grants update without extra work. After
Voidhash records the purchase, the SDK finishes it with the App Store or acknowledges it with Google
Play. Consumables are consumed so they can be bought again.
purchase() resolves to a better-result Result and never rejects. A cancelled purchase is an
Ok outcome, not an error.
const { purchase, error, isLoading } = voidhash.usePurchase({
onSuccess: () => {
navigation.goBack();
},
onError: (purchaseError) => {
console.warn("Purchase failed", purchaseError);
},
});
<Button
disabled={!monthly || isLoading}
title={isLoading ? "Purchasing…" : `Continue for ${monthly?.displayPrice ?? ""}`}
onPress={async () => {
if (!monthly) return;
const result = await purchase(monthly);
if (result.isOk() && result.value.status === "cancelled") {
// The customer dismissed the store sheet. Expected, not an error.
}
}}
/>;onSuccess runs only for a completed purchase. The Ok outcome is one of the following.
| Status | Meaning |
|---|---|
completed | Voidhash validated the purchase, and the person snapshot has been refreshed. |
deferred | The store completed the purchase, but it is still queued for Voidhash. It is sent later, and the SDK finishes it with the store once Voidhash accepts it. |
cancelled | The customer dismissed the native store sheet. |
pending | The purchase needs external action, such as a parent's approval. The SDK syncs and finishes it once the store completes it, even after a relaunch. |
disabled | The client was created with enabled: false. |
The Err side carries a coded VoidhashError: FAILED_TO_PURCHASE,
OFFER_UNAVAILABLE, or READ_ONLY_PURCHASE_NOT_ALLOWED in observer mode. To buy a store offer of
a product, call voidhash.client.purchase(product, { offerId }) with the offer ID from the
product's store configuration. When that offer cannot be bought, the purchase fails with
OFFER_UNAVAILABLE and the product is never bought at its regular price instead.
On Google Play, Voidhash also acknowledges a purchase the SDK started once it records it, in case the app stops before the SDK finishes it. Voidhash never acknowledges or consumes a purchase the SDK did not start.
A successful store sheet is not the entitlement check. Unlock the feature from the person's active
grants, for example with useHasPerk.
Report purchases in observer mode
In observer mode your existing billing integration starts purchases, restores access, and finishes or acknowledges store transactions. Voidhash reports the purchases it discovers without taking over those responsibilities. See Migrate in observer mode.
Call reportTransaction(...) after each successful purchase, including purchases from another SDK's
paywall. Report each transaction returned by a restore callback as well. Configure Voidhash and
identify the purchasing user before starting the host purchase flow.
Only the store identifier is required: an Apple transaction ID or a Google Play purchase token. Voidhash supplies its configured bundle/package and captures the current SDK identity automatically. The backend fetches and verifies the purchase with the store; timestamps, quantities, product metadata, receipts, signatures and purchaser arguments are unnecessary.
// Apple: keep the transaction ID as a string, preserving every digit.
await voidhash.client.reportTransaction({ platform: "ios", transactionId });
// Google Play: the purchase token, not the order ID.
await voidhash.client.reportTransaction({ platform: "android", purchaseToken });You can report as soon as the client exists. A report made while the client is still initializing,
or after end(), waits for initialization and is then stored. If the host exposes original
store data instead, the same method accepts a StoreKit JWS string or a Play bridge object with
originalJson. Voidhash extracts the identifier internally and discards the remaining payload;
no receipt-decoding dependency is needed in your application.
Legacy ReportedTransaction metadata such as productId, purchaseDate, quantity, receipt
and appAccountToken is optional and discarded. Android's optional purchaseState can mark a
report as pending or unspecified; those reports are ignored until reported as purchased.
The original Play JSON's pending state is converted internally. Multi-product bridge purchases
are rejected because the current purchase processing supports one product per purchase.
reportTransaction() returns Result<void, VoidhashError>. Missing or malformed required
identifiers return FAILED_TO_REPORT_TRANSACTION; VOIDHASH_CLIENT_NOT_INITIALIZED means the
client could not initialize. Network and delivery failures leave the report queued and return
Ok.
Reporting works after the host has finished or consumed a purchase because it does not scan the store. It never finishes, acknowledges or consumes transactions. Keep your billing integration's finalization and access checks in place.
A successful report is captured in the durable outbox before returning; delivery runs in the background. Success does not imply immediate backend acceptance. SDK diagnostics describe deferred delivery. Duplicate reports and observer callbacks use the same store identifier and retain the original captured identity through retries and app relaunches.
The SDK remembers on the device which store transaction Voidhash last accepted for each purchase, and who owns the purchase. A receipt Voidhash already has is never sent again by a report, an observer callback or a scan, even after the user signs out or another user signs in on the same device. A subscription renewal and an App Store refund or revocation are still reported, under the purchase's owner.
While the app is open, a report that could not be delivered is retried on its own timer, and again
at launch, on foreground, when connectivity returns and on flush(). Queued identity links are
sent first, so a purchase made right after identify() lands on the identified user. A receipt
Voidhash refuses with a verdict that cannot change on its own, for example because the App Store or
Google Play is not configured for this app, stays queued and is reported once through onDiagnostic
as TRANSACTION_REJECTED. It is sent again at the next launch or after six hours.
Recovery scans and restore callbacks
Use syncPurchases() for silent recovery when the host callback exposes no usable store
transaction values, including after its restore flow. It sends receipts already queued on the
device, reports purchases the store still exposes, skips receipts already accepted by Voidhash, and
refreshes the current person. It never moves a purchase to another user. It does not prompt for
store authentication. Store-read failures reach the caller; delivery failures remain queued.
const result = await voidhash.client.syncPurchases();
if (result.isErr()) console.warn("Voidhash purchase scan failed", result.error);Initialization installs the observer and scans purchases in the background. A foreground scans again at most once a minute; a connectivity restore can scan again ten seconds after the previous recovery. In observer mode, scans can report unconsumed or unfinished consumables that remain visible without finalizing them. They cannot recover consumed or finished consumables, or arbitrary expired subscription history. iOS scans pending transactions and current entitlements; Android queries currently owned purchases, including subscriptions whose auto-renew is off. On an iOS device where in-app purchases are restricted by Screen Time or device management, purchases are still observed, scanned and restored.
syncPurchases() is a fallback, not a delivery guarantee. If the host hides transaction values and
finishes a consumable before Voidhash captures it, a later callback or scan cannot recover it. Keep
explicit reporting in the successful purchase callback whenever your host SDK exposes the values.
Explicit restoration
Connect your Restore Purchases button to restorePurchases(). It revalidates restorable purchases
for the identity that requested the restore, even when Voidhash already accepted those receipts.
Only a restore can share or move a purchase that already gives another user access. The project's
paid access sharing setting decides the outcome: both keep access, access moves to the restoring
user, or it stays with the original user. A queued purchase retains its original buyer and is
delivered before the restore. A restore still queued when the app closes keeps that request after a
relaunch.
const result = await voidhash.client.restorePurchases();
if (result.isErr()) console.warn("Voidhash restore failed", result.error);On iOS, this calls AppStore.sync() and reads verified non-consumable transaction history,
including expired or revoked transactions. App Store authentication may appear, so call it only
from a user action. Android queries currently owned Play purchases; it cannot retrieve consumed
purchases or arbitrary expired subscription history.
Known consumables are excluded from explicit restoration. The restore waits for the current
transaction of each purchase; older transactions of a subscription are revalidated in the
background, so a long subscription history does not hold the Restore button. A store error, or a
receipt Voidhash could not confirm yet, fails the restore and the receipt stays queued for retry. A
receipt Voidhash refuses outright does not fail it and is reported as TRANSACTION_REJECTED. An
empty store can restore successfully. Success does not imply an active entitlement: use the
person's current grants to decide whether to unlock access.
Reporting and silent sync after a host restore discover purchases, but they leave a purchase that
already gives another user access with that user. When the user asked your own billing integration
to restore purchases, report each transaction it returns with { isRestore: true }:
await voidhash.client.reportTransaction({ platform: "ios", transactionId }, { isRestore: true });Such a report is sent as an explicit restore for the current user, like restorePurchases(), even
when Voidhash already accepted the receipt, and stays one across retries and app launches. Use
explicit restoration when Voidhash must revalidate cached receipts for the current user or give
them such a purchase.
Transactions missing from the dashboard
- In observer mode, confirm successful purchase callbacks call
reportTransaction(...)with the original store values. UsesyncPurchases()for restore-only callbacks that expose no transaction values. - Verify that Voidhash uses the intended project key and purchasing user, and that the dashboard is showing the matching project, person, and store environment.
- Check SDK diagnostics for store-read or delivery failures.
TRANSACTION_REJECTEDmeans Voidhash refused the receipt, usually because the App Store or Google Play is not configured for this app's bundle ID or package name, or the purchase belongs to another store environment. Analytics events arriving does not prove that a store transaction was discovered or submitted. - For scan-only integrations, confirm the purchase is still exposed by the store. A finished or consumed consumable requires captured transaction values; a successful host purchase alone is insufficient for a later scan.