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
Swift SDK
Swift quickstart
Display a paywall
Make and restore purchases
Check access
Identify users
Evaluate feature flags
Capture analytics
Groups
Track screens
Development mode
Swift SDK reference
Errors
Diagnostic codes
Troubleshooting
Theme

Make and restore purchases

Buy products, restore purchases, and report purchases another billing SDK makes.

The Swift SDK buys products through StoreKit 2, syncs each transaction with Voidhash, and only then finishes it with StoreKit. Paywalls handle purchases and restores for you; use the methods on this page when you build your own purchase screen.

Load products

getProducts() returns your project's products that the App Store sells, sorted by slug. getProducts(slugs:) returns the ones you name, in that order, with nil for a slug the App Store doesn't sell.

import SwiftUI
import Voidhash

struct PriceLabel: View {
    @State private var label = ""

    var body: some View {
        Text(label).task {
            guard let voidhash = Voidhash.shared,
                let products = try? await voidhash.getProducts(slugs: ["pro_annual"]),
                let product = products.first ?? nil,
                let subscription = product.subscription
            else { return }
            // "$49.99 a year, $4.17 a month"
            label = "\(product.price.formatted) a year, \(subscription.pricePerMonth.formatted) a month"
        }
    }
}

Each VoidhashProduct has:

PropertyWhat it is
productIdThe Voidhash product ID, which the person's subscriptions and purchases name.
slug, nameThe product's slug and name in Studio. id is the slug.
storeProductIdThe App Store product ID.
typesubscription, consumable, nonConsumable or nonRenewingSubscription.
displayName, descriptionThe App Store's localized title and description.
priceThe regular price: an exact Decimal amount, amountMicros, currencyCode, and formatted for the storefront.
subscriptionFor subscriptions only: the renewal period, groupId, introOffer, and pricePerDay to pricePerYear.
offersThe promotional and win-back offers this user can have.
appStoreEverything StoreKit says about the product, including the StoreKit.Product itself.

Show formatted as it is; it's the App Store's own string for the user's storefront. A period is a unit and a count; 7 days read as 1 week. Prices per unit count a year as 365 days, 52 weeks or 12 months and round to the currency's cents, so the same plan shows the same per-month price on iOS and Android.

A product the App Store doesn't return is left out and reported once per call as the PRODUCT_NOT_FOUND diagnostic, with its slug and why; so is a slug you asked for that has no App Store mapping (getProducts() leaves those out without a report). So is a product the App Store sells as a different type than Studio, reported as PRODUCT_TYPE_MISMATCH: a subscription in Studio needs an auto-renewable subscription in App Store Connect, a consumable a consumable, and a one-time product a non-consumable or a non-renewing subscription. See Products are empty.

Offers

subscription.introOffer is the free trial or introductory price the user is eligible for right now, or nil. offers lists promotional offers to current and former subscribers of the subscription group, as the App Store sells them: users no longer eligible for the introductory offer, or with a subscription in the group (bought at the regular price, for example), and, from iOS 18, the win-back offers the App Store says the user can have. Only the promotional offers you add to the product's App Store mapping in Studio are listed, because Voidhash signs only those. Each offer has an id (the offer ID you gave it in Studio, or a win-back offer's App Store offer ID), a kind and its pricing phases, each with a payment mode, a price per cycle, the length of a cycle and the number of cycles. appStore lists every offer, whatever the user's eligibility.

Eligibility changes with purchases and identities; every getProducts call asks the App Store again.

Start a purchase

purchase(_:options:) shows the App Store sheet. It returns how the purchase ended; only real failures throw:

import Voidhash
import VoidhashCore

func buy(_ product: VoidhashProduct) async {
    guard let voidhash = Voidhash.shared else { return }
    do {
        switch try await voidhash.purchase(product) {
        case .purchased(let transaction, let synced):
            // The App Store charged the user. `synced` is false while Voidhash hasn't accepted the
            // receipt yet; the SDK keeps sending it.
            print("Bought \(transaction.productSlug ?? transaction.storeProductId)")
        case .pending:
            // Ask to Buy or another approval is pending. It arrives through transactionUpdates().
            break
        case .cancelled:
            // The user closed the App Store sheet.
            break
        case .disabled:
            // The SDK is disabled; nothing was bought.
            break
        }
    } catch {
        print("Purchase failed: \(error)")
    }
}

By default a subscription is bought with its introductory offer when the user can have it. Pass VoidhashPurchaseOptions to choose otherwise:

OptionWhat it does
offer: .automaticThe introductory offer when the user can have it, otherwise the regular price. The default.
offer: .noneThe regular price. The App Store still applies an introductory offer the user is eligible for.
offer: .offer(offer), .id("id")One of the product's offers or its introOffer. .id takes the offer ID from Studio, or the App Store's.
quantityHow many to buy; more than 1 only for a consumable.
appStore.simulatesAskToBuyInSandboxMakes a sandbox purchase wait for approval, to test pending purchases.
appStore.confirmIn.scene(windowScene) or .viewController(controller) to show the App Store's confirmation on (a view controller from iOS 18.2).
_ = try await voidhash.purchase(product, options: VoidhashPurchaseOptions(offer: .id("winback_50")))

Voidhash signs promotional offers for the person; set up the In-App Purchase key in Offers. Win-back offers need iOS 18. When an offer can't be applied, for example because the user isn't eligible or Voidhash can't be reached to sign it, the call throws OFFER_UNAVAILABLE and buys nothing. It never falls back to the regular price. When the App Store refused the offer because the user isn't eligible for it, the error's reason is .ineligibleForOffer; load the products again before offering another purchase.

Only one purchase runs at a time: a second one while the first is open throws PURCHASE_IN_PROGRESS. A purchase started on the App Store meanwhile waits for the first to end. See Errors for every code.

Once the App Store has charged the user, the call waits up to 10 seconds for Voidhash. If Voidhash doesn't answer in time, it returns synced: false, keeps the receipt, sends it in the background and finishes the transaction once Voidhash accepts it.

Unlock features from the person's perks, not from the call returning. See Check access.

The returned VoidhashTransaction has the App Store transaction id, its series' originalId, the productSlug and storeProductId, purchasedAt, expiresAt, quantity, whether it isRenewal (iOS 17), the offer it was bought with, its environment, and an appStore part with the ownership type, the storefront country (two letters, such as US), upgrade and refund details and the signed jwsRepresentation.

Purchases that complete later

The SDK listens to Transaction.updates. Renewals, approved Ask to Buy requests, redeemed offer codes and purchases made on another device or while the app was closed are synced and finished on their own. Don't finish transactions in your own code as well. A transaction StoreKit can't verify is neither synced nor finished; the SDK reports it once as STOREKIT_TRANSACTION_UNVERIFIED.

To update your screen when one completes, listen to transactionUpdates():

import SwiftUI
import Voidhash

struct PurchaseActivityView: View {
    var body: some View {
        Text("Purchases")
            .task {
                guard let voidhash = Voidhash.shared else { return }
                for await update in voidhash.transactionUpdates() {
                    print("\(update.transaction.productSlug ?? "") completed")
                }
            }
    }
}

An update arrives once the SDK has handled its transaction. synced is true when Voidhash accepted the receipt and false while the receipt is queued on the device; the SDK keeps sending it. It's a signal to refresh your screen, not a guarantee of access: read access from the person, which personUpdates() delivers each time it changes, starting with the one saved on the device. Each call to either method gives a stream of its own. Purchases that purchase returned don't arrive again, and refunds, revocations and upgraded-from subscriptions don't arrive at all: they show in the person. Each transaction arrives once per app session; one Voidhash hasn't accepted yet can arrive again after the app relaunches. Up to 10 that complete before you listen are kept for your first stream.

Purchases started on the App Store

When a user buys a promoted in-app purchase or a win-back offer on the App Store, the SDK buys it once it has started, so the purchase belongs to the restored user, and delivers it through transactionUpdates(). To buy it yourself, for example after sign-in, set options.purchaseIntents = .manual and listen to voidhash.appStore.purchaseIntents():

for await intent in voidhash.appStore.purchaseIntents() {
    _ = try? await voidhash.purchase(intent.product, options: intent.purchaseOptions)
}

Restore purchases

Connect your Restore purchases button to restorePurchases(). It returns the refreshed person:

let person = try await voidhash.restorePurchases()
let hasPremium = person?.hasActivePerk("premium") ?? false

It calls AppStore.sync(), which may ask the user to sign in, so call it only from a user action. It sends the latest transaction of each subscription and non-consumable to Voidhash, even ones Voidhash already has, and refreshes the person before it returns. A successful restore doesn't mean the user has access: check the person's perks. A dismissed sign-in throws USER_CANCELLED.

A restore waits up to 20 seconds for Voidhash. If a receipt can't reach Voidhash, it throws RECONCILE_TRANSACTIONS_FAILED, keeps the receipts queued and sends them later. Consumables aren't restored.

Only a restore can give the current user a purchase that already grants access to another person. Your project's paid access sharing setting decides whether both keep access, access moves, or it stays with the original person.

Store sheets and the App Store

try await voidhash.showManageSubscriptions(product: product)  // opens the product's group (iOS 17)
let redeemed = try await voidhash.presentCodeRedemption()      // offer codes (iOS 16)
let storefront = await voidhash.getStorefront()                // storefront?.countryCode is "US"
let canPay = await voidhash.canMakePayments()                  // false when Screen Time blocks purchases

From iOS 27 the offer code sheet returns the redeemed transaction, already synced; on earlier versions it arrives through transactionUpdates(). The person is refreshed after the subscription management sheet closes.

voidhash.appStore reaches what only the App Store offers:

MethodWhat it does
beginRefundRequest(transactionId:)Shows Apple's refund request sheet. Returns .success or .userCancelled.
subscriptionStatus(groupId:)StoreKit's renewal state and renewal info of each subscription in a group on this device, the user's first.
currentEntitlements()The transactions StoreKit lists as entitling the Apple account.
appTransaction()When and in which version the user first got the app, signed (iOS 16).
messages(), display(_:)App Store messages, with options.appStoreMessages = .manual.
purchaseIntents()Purchases started on the App Store, with options.purchaseIntents = .manual.

App Store messages, such as a price increase to agree to, wait while a paywall is on screen and show afterwards. One that arrives while no window is in the foreground shows when the app is next active. Set options.appStoreMessages = .automatic to let the App Store show them at once.

For subscription status across devices and stores, read the person's subscriptions instead. See Check access.

Not supported

External purchase links and alternative billing, subscriptions with add-ons and purchases of several products at once, App Store pricing terms and billing plans, introductory offer eligibility overrides, and storefront change updates.

Report purchases from another billing SDK

In observer mode (options.readOnly = true), your existing billing SDK starts, restores and finishes purchases. Voidhash reports StoreKit transactions without finishing them, and purchase(_:options:) throws READ_ONLY_PURCHASE_NOT_ALLOWED. Purchases started on the App Store are left to your billing SDK.

Configure Voidhash and identify the user before the purchase starts. After each successful purchase, report the transaction. Only its ID is sent; Voidhash verifies it with the App Store.

import StoreKit
import Voidhash

func buy(_ product: Product) async throws {
    guard case .success(let verification) = try await product.purchase(),
        case .verified(let transaction) = verification
    else { return }

    try await Voidhash.shared?.reportTransaction(transaction)
    await transaction.finish()
}

reportTransaction also accepts a VerificationResult<Transaction> (it throws for an unverified one) or a transaction ID string: reportTransaction(transactionId:). Keep every digit of the ID. An empty ID throws INVALID_TRANSACTION.

A report is written to disk before the call returns and is delivered in the background, so an outage never fails your purchase callback. Reports work after your SDK finished or consumed the transaction. Voidhash never finishes, acknowledges or consumes a transaction you reported, in or out of observer mode and across relaunches: it stays yours to finish.

Report the transactions your billing SDK's restore returns with isRestore: true, so they're treated as a restore for the current user:

try await voidhash.reportTransaction(transaction, isRestore: true)

When a callback gives you no transaction, call syncPurchases(). It reports the pending transactions and current entitlements StoreKit still exposes, skips receipts Voidhash already accepted, and refreshes the person if Voidhash answers within 10 seconds. It doesn't prompt for sign-in. It can't recover a consumable your SDK already finished, so prefer reportTransaction.

In observer mode the SDK still listens to Transaction.updates and reports renewals and Ask to Buy approvals; your own listener finishes them.

Receipts waiting for Voidhash

Every receipt goes to a queue on the device before it's sent and stays there until Voidhash accepts it, across launches and outages. The SDK retries on its own, scans for missed transactions at launch, and scans again when the app returns to the foreground or regains connectivity, at most once a minute each. When Voidhash answers a receipt with Retry-After, every queued receipt waits until then; only a restore the user asks for is sent sooner.

pendingTransactionCount() returns how many receipts are waiting. syncPendingWork() sends everything queued now, without waiting out the retry delay: identify calls and attributes, then receipts, then events. It waits up to 2 seconds and continues in the background.

if await voidhash.pendingTransactionCount() > 0 {
    await voidhash.syncPendingWork()
}

When Voidhash refuses a receipt for good, for example because the App Store isn't connected for your bundle ID, the SDK reports TRANSACTION_REJECTED and tries again on the next launch, after 6 hours, or on a restore.

Purchases from an Xcode StoreKit configuration file aren't sent, because the App Store can't verify them. The SDK reports STOREKIT_TESTING_TRANSACTION_SKIPPED once per session. Test with a sandbox account or in development mode.

Next steps

  • Check access
  • Errors
  • Test purchases (Help center)
  • Troubleshooting

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 purchasePurchases that complete laterPurchases started on the App StoreRestore purchasesStore sheets and the App StoreNot supportedReport purchases from another billing SDKReceipts waiting for VoidhashNext steps