Make and restore purchases
Load products and offers, buy and restore them, follow purchase updates, 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 your project's products sorted by slug. getProducts(slugs)
returns the products you name, in the order you name them, with null for one that can't be
loaded. With no project configuration loaded yet, for example on a cold offline launch, the list is
empty. A Google Play failure throws FAILED_TO_GET_PRODUCTS.
val (yearly, monthly) = voidhash.getProducts(listOf("pro_yearly", "pro_monthly"))| Field | Meaning |
|---|---|
slug, name | The product's slug and name in Studio. |
productId | The product's Voidhash ID, as a person's subscriptions name it. |
storeProductId | The Google Play product ID. |
type | SUBSCRIPTION, CONSUMABLE or NON_CONSUMABLE. |
displayName, description | The product's name and description in Google Play. |
price | The regular price: formatted by Google Play for the person's country (59,99 €), the exact amount, amountMicros and currencyCode. |
subscription | For a subscription: its period, its introOffer, and the price per day, week, month and year. |
offers | The other offers the person can buy the product with. |
googlePlay | Everything Google Play returned, its ProductDetails included, for a purchase screen that needs more. |
Each product in Studio is one product, so two base plans of the same Google Play subscription are
two products. A subscription's price and period are its base plan's regular price, not a free
trial ahead of it. pricePerMonth and the others spread that price evenly, a month being a twelfth
of a year: a yearly plan at 49.99 USD costs 4.17 per month and 0.96 per week.
A product that can't be loaded is left out, and reported once per call as the PRODUCT_NOT_FOUND
diagnostic with the reason: not returned by Google Play (inactive, or no
offer this person is eligible for), its base plan not returned, or, for a slug you asked for, not
configured for Google Play. getProducts() leaves products without a Google Play mapping out
without a report. A product Google Play sells as a subscription when Studio has a one-time product,
or the reverse, is reported as PRODUCT_TYPE_MISMATCH, with both types.
Check its mapping in Products and perks and the
troubleshooting steps.
Offers
An offer has an id (the ID your product configuration gives it, otherwise
Google Play's offer ID), a kind, and for a subscription the phases before the regular price:
each a free trial, a price paid every period, or a price paid up front, with its length.
Google Play returns an offer for new customers only to people eligible for it, but returns an offer you target yourself to everyone. So among the offers on a subscription's base plan:
subscription.introOfferis the offer taggedvoidhash-introin the Play Console, otherwise the offer with the longest free trial that your product configuration doesn't name and that isn't taggedvoidhash-ignore. An offer starting with a paid discount is never chosen on its own; tag itvoidhash-introto make it the introductory offer.offersare the offers your product configuration names, then every other offer Google Play returned that isn't taggedvoidhash-ignore.
Google Play returns developer-determined offers to every person: an untagged
developer-determined free trial becomes introOffer and is applied to every purchase by default,
also for people who had a trial before. Tag such offers voidhash-ignore in the Play Console, or
tag the trial you intend voidhash-intro. Tag any other offer meant only for some people, such as
a win-back offer, voidhash-ignore too, or name it in your product configuration and buy it only
by its ID. Otherwise everyone sees it in offers.
A one-time product's discounted offers in Google Play are its offers too.
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. The SDK then
acknowledges the purchase, or consumes it for a consumable product, without purchase waiting for
it. If that fails, the SDK finishes the purchase later.
import com.voidhash.sdk.VoidhashException
import com.voidhash.sdk.billing.VoidhashPurchaseResult
import kotlinx.coroutines.CancellationException
lifecycleScope.launch {
try {
when (voidhash.purchase(activity, monthly)) {
is VoidhashPurchaseResult.Purchased ->
if (voidhash.getCurrentPerson()?.hasActivePerk("premium") == true) unlockPremium()
VoidhashPurchaseResult.Pending -> showPaymentPending()
VoidhashPurchaseResult.Cancelled, VoidhashPurchaseResult.Disabled -> Unit
}
} catch (error: CancellationException) {
throw error
} catch (error: VoidhashException) {
reportError(error)
}
}| Result | Meaning |
|---|---|
Purchased(transaction, synced) | Google Play charged the person. synced says whether Voidhash had recorded it before purchase returned. |
Pending | The payment awaits completion, such as a cash payment. It arrives later through purchase updates. |
Cancelled | The person closed the sheet. |
Disabled | Voidhash was configured with enabled = false. |
Rethrow CancellationException so coroutine cancellation keeps working. Every failure is a
VoidhashException with a stable code. 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. PURCHASE_IN_PROGRESS means another purchase is still running. See
Purchase codes.
A closed Google Play sheet doesn't prove access. Unlock a feature only when the person has an active perk.
Choose the offer
By default, a subscription is bought with its introOffer when the person can get one, and at its
regular price otherwise. A plan change (see Change a subscription) applies
the introOffer by default only when it's tagged voidhash-intro. Choose another with
VoidhashPurchaseOptions:
import com.voidhash.sdk.billing.VoidhashOfferSelection
import com.voidhash.sdk.billing.VoidhashPurchaseOptions
voidhash.purchase(activity, monthly, VoidhashPurchaseOptions(offer = VoidhashOfferSelection.Id("win_back")))
voidhash.purchase(activity, monthly, VoidhashPurchaseOptions(offer = VoidhashOfferSelection.None))Offer(offer) and Id(id) buy one of the product's offers or its introOffer; None buys the
regular price. 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. When Google Play
refuses an offer because the person's Google account isn't eligible for it, such as a trial they
had before, purchase throws a VoidhashPurchaseException whose reason is
INELIGIBLE_FOR_OFFER; a payment declined for insufficient funds has the reason PAYMENT_DECLINED.
For a purchase screen that needs more, googlePlay.offerToken buys any base plan, offer or
purchase option from the product's googlePlay part, googlePlay.obfuscatedProfileId sets Google
Play's profile ID, and googlePlay.isOfferPersonalized tells Google Play the price was
personalized. Google Play lets the person choose a quantity in its own sheet, so quantity must be
1; the transaction reports the quantity bought.
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
import com.voidhash.sdk.billing.VoidhashPurchaseOptions
voidhash.purchase(
activity,
yearly,
VoidhashPurchaseOptions(replacing = SubscriptionReplacement(monthly, SubscriptionReplacementMode.WITH_TIME_PRORATION)),
)The SDK finds the person's current purchase of the replaced product itself, also when both are base plans of the same Google Play subscription. 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. |
DEFERRED | Nothing now. The person keeps the old subscription until it renews, and the new one starts then. Suits downgrades. |
Google Play accepts fewer modes for some plans. Replacing any subscription with a prepaid plan
takes CHARGE_FULL_PRICE. Switching to an auto-renewing base plan of the same Google Play
subscription, from a prepaid or an auto-renewing one, takes CHARGE_FULL_PRICE or
WITHOUT_PRORATION. purchase throws INVALID_ARGUMENT for any other mode before the sheet opens.
With DEFERRED, Google Play reports the purchase with the old product until the change takes
effect, so the returned transaction names the old product, and the person's subscription shows the
coming change as renewsToProductSlug. 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,
both are the same, or Google Play doesn't accept the mode for the plans. A purchase made in a
paywall can't replace a subscription yet.
Follow purchase updates
person is a flow of the current person, updated after every purchase, restore, identify and
refresh. transactionUpdates is a flow of purchases that complete outside purchase: a pending
payment that went through, a promo code redeemed in the Play Store, or a purchase whose purchase
call went away while the sheet was open. Each update carries the transaction and synced.
lifecycleScope.launch { voidhash.person.collect { render(it) } }
lifecycleScope.launch { voidhash.transactionUpdates.collect { update -> showThanks(update.transaction) } }An update arrives once the SDK has handled the purchase: Voidhash recorded it (synced is true),
or it's queued on the device (false). It's a signal for your UI, not a delivery guarantee — an update arriving while nothing
collects isn't replayed — and access always comes from the person's perks. Google Play renews a
subscription under the purchase it already has, so renewals don't arrive as updates; the person
shows them.
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 Purchased with synced = false, 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. Google Play gives a prepaid plan shorter than a week only half its duration, so the check acknowledges such a purchase halfway through that, 6 hours after payment for a one-day plan. 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(). It returns the refreshed person,
or null when Voidhash has none for this device:
val person = voidhash.restorePurchases()
if (person?.hasActivePerk("premium") != true) showNothingToRestore()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.
Manage subscriptions
person.subscriptions.all lists every subscription the person has that hasn't ended, including one
in a grace period, billing retry, on hold or paused — what a subscription screen shows — with its
status, expiresAt, willRenew and coming changes. subscriptions.current is the one that
matters most, and subscriptions.history and purchases.history list the rest. Grant access from
perks, never from these lists.
| Call | What it does |
|---|---|
showManageSubscriptions(activity, product?) | Opens Google Play's subscription settings for the product's subscription, or for all of them. |
presentCodeRedemption(activity, code?) | Opens Google Play's promo code page, with the code filled in. A redeemed code arrives as a purchase update. |
getStorefront() | The country Google Play bills the person in, such as US. |
canMakePayments() | Whether Google Play can be reached and can bill the signed-in account. |
Google Play features
voidhash.googlePlay holds what only Google Play offers:
showInAppMessages(activity)shows Google Play's messages, such as a prompt to fix a declined subscription payment. When the person fixes a subscription, the SDK sends the purchase to Voidhash and refreshes the person before it returns.queryPurchases()lists the purchases the Google Play account owns, including subscriptions Google Play paused or put on hold (googlePlay.isSuspended). A suspended subscription grants no access; the SDK never sends, acknowledges or consumes it.isFeatureSupported(feature)asks the Play Store about aBillingClient.FeatureType.
Products and transactions carry Google Play Billing's own types, so the SDK depends on its major version: this release uses Google Play Billing 9.
External purchase links, alternative billing and other Google Play billing programs, subscriptions with add-ons, multi-line purchases, and top-ups of prepaid plans aren't supported.
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.