VoidhashDocs
DevelopersHelp centerGuidesAPI reference
voidhash.comOpen Studio
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Groups
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
Kotlin SDK
Kotlin quickstart
Display a paywall
Make and restore purchases
Check access
Identify users
Evaluate feature flags
Capture analytics
Groups
Track screens
Development mode
Kotlin SDK reference
Errors
Diagnostic codes
Troubleshooting
Theme

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"))
FieldMeaning
slug, nameThe product's slug and name in Studio.
productIdThe product's Voidhash ID, as a person's subscriptions name it.
storeProductIdThe Google Play product ID.
typeSUBSCRIPTION, CONSUMABLE or NON_CONSUMABLE.
displayName, descriptionThe product's name and description in Google Play.
priceThe regular price: formatted by Google Play for the person's country (59,99 €), the exact amount, amountMicros and currencyCode.
subscriptionFor a subscription: its period, its introOffer, and the price per day, week, month and year.
offersThe other offers the person can buy the product with.
googlePlayEverything 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.introOffer is the offer tagged voidhash-intro in the Play Console, otherwise the offer with the longest free trial that your product configuration doesn't name and that isn't tagged voidhash-ignore. An offer starting with a paid discount is never chosen on its own; tag it voidhash-intro to make it the introductory offer.
  • offers are the offers your product configuration names, then every other offer Google Play returned that isn't tagged voidhash-ignore.
Tag your developer-determined offers

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)
    }
}
ResultMeaning
Purchased(transaction, synced)Google Play charged the person. synced says whether Voidhash had recorded it before purchase returned.
PendingThe payment awaits completion, such as a cash payment. It arrives later through purchase updates.
CancelledThe person closed the sheet.
DisabledVoidhash 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.

Unlock from the perk, not the sheet

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:

ModeWhat Google Play charges
WITH_TIME_PRORATIONNothing now. The time left on the old subscription becomes time on the new one, which moves the next billing date. The default.
CHARGE_PRORATED_PRICEThe price difference for the rest of the period, now. The billing date stays. Upgrades only.
CHARGE_FULL_PRICEThe full price of the new subscription, now. What was left of the old one is credited to it.
WITHOUT_PRORATIONThe new price, first on the next billing date, which stays.
DEFERREDNothing 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.onCreate so 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.

CallWhat 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 a BillingClient.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.

PreviousDisplay a paywallNextCheck access
© 2026 Voidhash s.r.o.
voidhash.comPricingTermsPrivacyDiscord

On this page

Load productsOffersStart a purchaseChoose the offerChange a subscriptionFollow purchase updatesWhen Voidhash can't be reachedRestore purchasesManage subscriptionsGoogle Play featuresReport purchases in observer modeReport each purchaseRecover with a scanNext steps