VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
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

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" }
FieldMeaning
slugThe product's slug in Studio.
idThe Google Play product ID.
displayName, descriptionThe product's name and description.
displayPriceThe price formatted by Google Play for the person's country, such as 59,99 €.
price, currencyThe same price as a number (59.99) and its ISO 4217 code.
billingPeriodISO 8601 period of a subscription's recurring price, such as P1M; null for one-time products.
isSubscriptionWhether the product is a subscription.
googlePlayOfferTokensThe 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.

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.

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:

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.

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.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. 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.

PreviousDisplay a paywallNextCheck access

On this page

Load productsStart a purchaseChange a subscriptionWhen Voidhash can't be reachedRestore purchasesReport purchases in observer modeReport each purchaseRecover with a scanNext steps