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

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 returned, sorted by slug, with prices formatted for the user's storefront.

import SwiftUI
import Voidhash

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

    var body: some View {
        Text(price).task {
            let products = (try? await Voidhash.shared?.getProducts()) ?? []
            price = products.first { $0.slug == "pro_monthly" }?.displayPrice ?? ""
        }
    }
}

Each VoidhashProduct has id, slug, name, displayName, description, displayPrice, price, currency, type, interval and providerProductId, the App Store product ID. A product without an App Store mapping, or one the App Store doesn't return, is left out. See Products are empty.

Start a purchase

purchase(product:) shows the App Store sheet. Cancellation and pending approval throw, so handle them apart from real failures:

import Voidhash
import VoidhashCore

func buy(_ product: VoidhashProduct) async {
    guard let voidhash = Voidhash.shared else { return }
    do {
        try await voidhash.purchase(product: product)
    } catch let error as VoidhashStoreError where error.code == "USER_CANCELLED" {
        // The user closed the App Store sheet.
    } catch let error as VoidhashStoreError where error.code == "PURCHASE_PENDING" {
        // Ask to Buy or another approval is pending. Access arrives once it clears.
    } catch {
        print("Purchase failed: \(error)")
    }
}

USER_CANCELLED also covers a dismissed App Store sign-in. purchase(productSlug:) buys by slug and throws INVALID_PRODUCT_ID when the App Store sells no product with that slug. See Errors for every code.

Both return a PurchaseOutcome:

OutcomeMeaning
.completedVoidhash accepted the purchase and the person was refreshed.
.deferredThe App Store charged the user, but Voidhash didn't answer within 10 seconds. The SDK 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 SDK also listens to Transaction.updates. Renewals, approved Ask to Buy requests 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.

Promotional offers

Pass the offer ID your product mapping names to buy a promotional offer:

try await voidhash.purchase(product: product, offerId: "winback_50")

When the 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. Offers are set up in Offers.

Restore purchases

Connect your Restore purchases button to restorePurchases():

try await voidhash.restorePurchases()
let hasPremium = try await voidhash.getCurrentPerson()?.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: read the person afterwards.

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

Present the App Store's offer code redemption and subscription management sheets:

try await voidhash.presentCodeRedemptionSheet()
try await voidhash.showManageSubscriptions()

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(product:) throws READ_ONLY_PURCHASE_NOT_ALLOWED.

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

On this page

Load productsStart a purchasePromotional offersRestore purchasesStore sheetsReport purchases from another billing SDKReceipts waiting for VoidhashNext steps