VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Products and perks
Paywall locations
CLI
Paywall components
Components
Develop a component
Editor panels
Component definition
Publishing
Installing and capabilities
SDKs
Swift
Kotlin
React Native
Web
Store setup
Connect the App Store
Connect Google Play
Connect Stripe
Record payments from other sources
API libraries
Node.js
PHP
Rust
Go
Resources
MCP server
Ad measurement
Meta ads
Migrate in observer mode
Delete a person's data
Kotlin SDK
Kotlin quickstart
Display a paywall
Make and restore purchases
Check access
Identify customers
Evaluate feature flags
Capture analytics
Track screens
Development purchase mode
Errors

Make and restore purchases

Buy products, restore purchases, and report purchases from another billing integration.

Paywalls handle purchases and restores for you. Use the APIs on this page when you build your own purchase screen, or when another billing integration owns your purchases.

By default the SDK owns purchases: it starts them with Play Billing, syncs them with Voidhash, and acknowledges or consumes them with Google Play. If your existing billing integration starts purchases and finishes store transactions, configure the SDK in observer mode with readOnly = true and report its purchases instead, as described in Report purchases in observer mode. See Migrate in observer mode for a step-by-step migration.

Load products

getProducts() returns the store-backed products configured in your project. displayPrice comes already formatted for the customer's storefront, so you can render it directly. price is the same amount as a number (59.99 for "59,99 €") and currency is its ISO code. For a subscription, both describe the recurring price of the base plan configured in your project, not a free trial or introductory phase ahead of it; billingPeriod is that price's period and googlePlayOfferToken is the base plan's own offer. A Google Play failure throws FAILED_TO_GET_PRODUCTS.

val products = voidhash.getProducts()
val monthly = products.firstOrNull { it.slug == "monthly" }

If a product is missing from the result, the store did not return it. That happens when the product is not available on the current store account, environment, or provider configuration.

Start a purchase

purchase launches the Play Billing flow from an activity and syncs the resulting transaction to Voidhash for server validation. Once Voidhash accepts it, the SDK acknowledges the transaction, or consumes it for one-time-consumable products. On success the SDK refreshes the current person, so entitlement grants update without extra work.

lifecycleScope.launch {
    try {
        voidhash.purchase(activity, monthly)
        finish()
    } catch (error: CancellationException) {
        throw error
    } catch (error: VoidhashException) {
        if (error.code != "USER_CANCELLED") reportError(error)
    }
}

The CancellationException branch rethrows so that coroutine cancellation keeps working. Every other failure surfaces as VoidhashException with a stable code. A customer who closes the Play sheet raises USER_CANCELLED; treat it as an expected result. See Errors for the full list.

To buy a specific offer of a subscription, pass its offerId as your product configuration names it. If Google Play does not offer it to this customer, purchase throws OFFER_UNAVAILABLE and nothing is bought. It never buys the product at its regular price instead.

voidhash.purchase(activity, monthly, offerId = "intro-offer")

The purchase is written to the device before it is sent to Voidhash. If Voidhash cannot be reached, purchase still returns the transaction. The SDK delivers it in the background and then acknowledges or consumes it.

Google Play refunds a purchase left unacknowledged for three days. If the app stops after Voidhash records a purchase the SDK started, but before the SDK acknowledges it, Voidhash's servers acknowledge it instead.

Grant access from Voidhash state

A successful store dialog is not the entitlement check. Unlock the feature only after the refreshed person contains an active grant.

In observer mode, purchase throws READ_ONLY_PURCHASE_NOT_ALLOWED before it opens the Play Billing flow. To test purchases without Google Play setup, use development purchase mode.

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. The project's paid access sharing setting decides what happens to purchases that already give another user access: both keep access, access moves to the restoring user, or it stays with the original user. A queued restore keeps that request across app relaunches. A queued purchase retains its original buyer and is delivered before the restore.

voidhash.restorePurchases()

This queries currently owned Play purchases. Google Play has no separate restore prompt and does not return consumed purchases or arbitrary expired subscription history. Known consumables are excluded from explicit restoration. A store error fails the restore with BILLING_CONNECTION_FAILED or STORE_UNAVAILABLE. A receipt that was not accepted fails it with RECONCILE_TRANSACTIONS_FAILED: a deferred receipt stays queued for retry, and one the backend refused stays queued and is retried on the next launch. 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:

// In an app-owned coroutine.
voidhash.reportTransaction(purchase, 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.

Report purchases in observer mode

In observer mode, your billing integration starts purchases, restores access, and finishes or acknowledges store transactions. Voidhash records the purchases it discovers without taking over those responsibilities, on the device or on its servers.

Report a completed purchase or restore

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.

// In an app-owned coroutine; use the purchase token, not the order ID.
voidhash.reportTransaction(purchaseToken)

You can also pass a Google Play Billing Purchase directly. Voidhash reads its token and purchase state, then discards other fields, original JSON and signature. Pending purchases are ignored; report them again when purchased. A token-only report from a completed callback needs no purchase time, quantity, product ID or order ID. The server verifies the current state with Google Play.

Blank tokens and multi-product Purchase objects throw INVALID_TRANSACTION. Reporting does not require a Billing connection; delivery failures stay queued without throwing into the host purchase callback.

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 written to the device before returning; delivery runs in the background. Success does not imply immediate backend acceptance. Each delivery attempt that fails or is not confirmed is reported through onDiagnostic as TRANSACTION_SYNC_DEFERRED; the receipt stays queued and is retried. If the device cannot store the report, for example because its disk is full, reportTransaction throws TRANSACTION_NOT_DURABLE. Delivery is still attempted, but the report would not survive the app closing, so delay consuming the purchase and report it again later. Duplicate reports and observer callbacks use the same store identifier and retain the original captured identity through retries and app relaunches. A purchase token Voidhash has already accepted is not sent again.

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 reports purchases the store still exposes, skips receipts already accepted by Voidhash, and refreshes the current person. It does not prompt for store authentication. Store-read failures reach the caller as BILLING_CONNECTION_FAILED or STORE_UNAVAILABLE; delivery failures remain queued.

The device remembers which receipts Voidhash accepted and for whom, so when another user signs in on the same device and Google account, scans do not submit the earlier user's purchases for them. A renewal is reported for the person who owns the subscription. A report or scan that does name a purchase that already gives another user access records it for that user; only a restore can share or move it.

// In an app-owned coroutine; handle errors as above.
voidhash.syncPurchases()

Initialization installs the observer and scans purchases in the background. Foreground and connectivity recovery also scan with a one-minute throttle per trigger. 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.

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.

Transactions missing from the dashboard

  • In observer mode, confirm successful purchase callbacks call reportTransaction(...) with the original store values. Use syncPurchases() 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_SYNC_DEFERRED means a receipt is still waiting for delivery. Analytics events arriving does not prove that a store transaction was discovered or submitted.
  • A TRANSACTION_REJECTED diagnostic means the backend refused the receipt and will keep doing so, for example because Google Play is not configured for the app's package name (such as a debug build's applicationId suffix) or the token belongs to another app. The receipt stays on the device and is sent again on the next launch, or after six hours in a long session; fix the configuration and relaunch.
  • 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.

Next steps

  • Check active entitlement grants
  • Structured error codes
  • Kotlin SDK setup
  • Use observer mode during a migration
PreviousDisplay a paywallNextCheck access

On this page

Load productsStart a purchaseExplicit restorationReport purchases in observer modeReport a completed purchase or restoreRecovery scans and restore callbacksTransactions missing from the dashboardNext steps