Make and restore purchases
Load products, buy and restore them, and report purchases in observer mode.
The purchase APIs load your products, run the Google Play purchase sheet, and restore what a person already owns. Paywalls call them for you; use them directly when you build your own purchase screen, or report purchases when another billing integration owns them.
Load products
getProducts() returns the products configured in your project that Google Play returned for this
device. With no project configuration loaded yet, for example on a cold offline launch, it returns
an empty list. A Google Play failure throws FAILED_TO_GET_PRODUCTS.
val products = voidhash.getProducts()
val monthly = products.firstOrNull { it.slug == "pro_monthly" }| Field | Meaning |
|---|---|
slug | The product's slug in Studio. |
id | The Google Play product ID. |
displayName, description | The product's name and description. |
displayPrice | The price formatted by Google Play for the person's country, such as 59,99 €. |
price, currency | The same price as a number (59.99) and its ISO 4217 code. |
billingPeriod | ISO 8601 period of a subscription's recurring price, such as P1M; null for one-time products. |
isSubscription | Whether the product is a subscription. |
googlePlayOfferTokens | The offers this person can buy, by offer ID. |
For a subscription, price and billingPeriod describe the base plan's recurring price, not a
free trial or introductory phase. A product missing from the list wasn't returned by Google Play:
check its mapping in Products and perks and the
troubleshooting steps.
Start a purchase
purchase opens the Google Play sheet from activity and sends the purchase to Voidhash for
validation. Once Voidhash accepts it, purchase refreshes the current person and returns a
VoidhashTransaction. The SDK then acknowledges the purchase, or consumes it for a consumable
product, without purchase waiting for it. If that fails, purchase still succeeds and the SDK
finishes the purchase later.
import com.voidhash.sdk.VoidhashException
import kotlinx.coroutines.CancellationException
lifecycleScope.launch {
try {
voidhash.purchase(activity, monthly)
if (voidhash.getCurrentPerson()?.hasActivePerk("premium") == true) unlockPremium()
} catch (error: CancellationException) {
throw error
} catch (error: VoidhashException) {
if (error.code != "USER_CANCELLED") reportError(error)
}
}Rethrow CancellationException so coroutine cancellation keeps working. Every other failure is a
VoidhashException with a stable code. USER_CANCELLED means the person closed the sheet, and
PURCHASE_PENDING means the payment awaits approval; neither is an error to show.
ITEM_ALREADY_OWNED means the person already owns the product, so offer to restore.
STORE_UNAVAILABLE means Google Play couldn't be reached; trying again later may work. See
Purchase codes.
A closed Google Play sheet doesn't prove access. Unlock a feature only when the person has an active perk.
To buy an offer, pass its offer ID as your product
configuration names it. If Google Play doesn't offer it to this person, purchase throws
OFFER_UNAVAILABLE and buys nothing; it never falls back to the regular price.
voidhash.purchase(activity, monthly, offerId = "intro_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:
import com.voidhash.sdk.billing.SubscriptionReplacement
import com.voidhash.sdk.billing.SubscriptionReplacementMode
val yearly = voidhash.getProducts().first { it.slug == "pro_yearly" }
voidhash.purchase(
activity,
yearly,
replacing = SubscriptionReplacement(monthly, SubscriptionReplacementMode.WITH_TIME_PRORATION),
)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: purchase throws
REPLACED_SUBSCRIPTION_NOT_OWNED when the Google Play account has no paid purchase of the replaced
product, and INVALID_ARGUMENT when either product isn't a subscription or both are the same. A
purchase made in a paywall can't replace a subscription yet.
When Voidhash can't be reached
The SDK writes the purchase to the device before sending it. If Voidhash doesn't answer within
10 seconds, purchase still returns, and the person may not show the perk yet: read
getCurrentPerson() again before you deny access. The SDK keeps sending the purchase in the
foreground with growing waits up to 30 seconds, once when the app goes to the background, and at
the next launch. It acknowledges the purchase once Voidhash accepts it.
Google Play refunds a purchase left unacknowledged for 3 days. To prevent that:
- If the app stops after Voidhash recorded a purchase but before the SDK acknowledged it, Voidhash's servers acknowledge it.
- For a purchase Voidhash hasn't recorded, the SDK schedules a background check every 4 hours while
the device is online, even when your app is closed. Configure Voidhash in
Application.onCreateso the check can send queued purchases. - If a purchase is still unacknowledged 36 hours after payment, the check acknowledges it on the
device and reports
PURCHASE_FINISHED_BEFORE_SYNC. The SDK still delivers it; it consumes a consumable only once Voidhash has recorded it.
The trade-off: a purchase Voidhash refuses for good, reported as TRANSACTION_REJECTED, stays
charged without access until you resolve it, and Google Play no longer refunds it automatically.
The check covers only purchases the SDK starts.
Restore purchases
Connect your Restore purchases button to restorePurchases():
voidhash.restorePurchases()It sends every purchase Google Play still reports as owned for the current person, even ones Voidhash already accepted, and refreshes the person. Google Play doesn't return consumed purchases or expired subscriptions, and known consumables are skipped. Your project's paid access sharing setting decides what happens to a purchase that already gives another person access.
A successful restore doesn't mean the person has access; check their perks. A store failure throws
BILLING_CONNECTION_FAILED or STORE_UNAVAILABLE. A purchase Voidhash didn't accept throws
RECONCILE_TRANSACTIONS_FAILED; the SDK keeps it and retries. Once Google Play has listed the
purchases, a restore waits at most 10 seconds for Voidhash. After that it throws
RECONCILE_TRANSACTIONS_FAILED and continues in the background, with every purchase it listed
already queued on the device.
Report purchases in observer mode
In observer mode, your existing billing integration starts, acknowledges
and consumes purchases, and Voidhash only records them. Turn it on with readOnly = true in
VoidhashOptions, or at runtime with setReadOnly(true); a purchase already under way keeps its
mode. purchase then throws READ_ONLY_PURCHASE_NOT_ALLOWED. Configure Voidhash and
identify the person before your integration starts a purchase.
Report each purchase
Call reportTransaction in your integration's purchase callback, with the Play Billing Purchase
or its purchase token (not the order ID):
// In your PurchasesUpdatedListener, from a coroutine
voidhash.reportTransaction(purchase)
voidhash.reportTransaction(purchase.purchaseToken)Voidhash verifies the token with Google Play; no other purchase data is needed. The SDK never acknowledges or consumes a reported purchase, so reporting works after your integration has. Pending purchases are ignored: report them again once purchased.
reportTransaction returns once the report is stored on the device and delivers it in the
background, retrying until Voidhash accepts it. Delivery failures never throw; each is reported as
the TRANSACTION_SYNC_DEFERRED diagnostic. It throws
INVALID_TRANSACTION for a blank token or a multi-product purchase, and TRANSACTION_NOT_DURABLE
when the device couldn't store the report, or hasn't been unlocked since it started: hold off
consuming the purchase and report it again.
When a person asks your integration to restore, report each returned purchase with
isRestore = true. Voidhash then treats it like restorePurchases(), so the paid access sharing
setting applies.
voidhash.reportTransaction(purchase, isRestore = true)Recover with a scan
syncPurchases() sends the purchases Google Play still reports as owned that Voidhash hasn't
accepted, then refreshes the person. Once Google Play has listed the purchases, it waits at most
10 seconds for Voidhash; delivery continues in the background. Every purchase it listed is stored
on the device before it returns, so you can consume a purchase once syncPurchases() has returned.
Use it after a callback that exposes no purchase token:
voidhash.syncPurchases()The SDK also scans at initialize(), and when the app returns to the foreground or regains a
network, at most once a minute each. A scan can't recover a consumable your integration already
consumed, so report purchases from the callback whenever you can. Scans skip purchases another
person on the same device already owns.
Next steps
- Check access after a purchase.
- Run in observer mode during a migration.
- Test purchases (Help center) with license testers or development mode.
- Errors for every code.
Something wrong or missing on this page? Tell us by email or on Discord.